Все о памяти JVM вне кучи

JVM C++

Объекты в Java размещаются в куче JVM, и преимущество заключается в том, что разработчикам не нужно заботиться об утилизации объектов. Но есть преимущества и недостатки.Есть два основных недостатка памяти в куче: 1.Сборка мусора имеет стоимость.Чем больше объектов в куче, тем больше накладные расходы на сборку мусора. 2. При использовании памяти в куче для файлового и сетевого ввода-вывода JVM будет использовать память вне кучи для дополнительной передачи, то есть еще одной копии памяти.

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

Давайте сначала рассмотрим принцип реализации памяти вне кучи, а затем поговорим о сценариях ее применения.

Больше статей смотрите в личном блоге:GitHub.com/farmer Джон Брат…

Реализация памяти вне кучи

В Java есть два способа выделения памяти вне кучи, один из них — черезByteBuffer.java#allocateDirectПолучите объект DirectByteBuffer, второй вызов напрямуюUnsafe.java#allocateMemoryВыделить память, но Unsafe можно вызвать только в коде JDK, и обычно этот метод не использует этот метод для непосредственного выделения памяти.

Среди них DirectByteBuffer также использует Unsafe для реализации выделения памяти и инкапсулирует выделение, чтение и запись, а также повторное использование памяти кучи. Содержание этой статьи также предназначено для анализа реализации DirectByteBuffer.

Мы анализируем DirectByteBuffer с точки зрения выделения и утилизации памяти вне кучи, чтения и записи.

Выделение и освобождение памяти вне кучи

//ByteBuffer.java 
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

ByteBuffer#allocateDirectТолько что создал объект DirectByteBuffer, сосредоточившись на методе построения DirectByteBuffer.

DirectByteBuffer(int cap) {                   // package-private
    //主要是调用ByteBuffer的构造方法,为字段赋值
    super(-1, 0, cap, cap);
    //如果是按页对齐,则还要加一个Page的大小;我们分析只pa为false的情况就好了
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    //预分配内存
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        //分配内存
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    //将分配的内存的所有值赋值为0
    unsafe.setMemory(base, size, (byte) 0);
    //为address赋值,address就是分配内存的起始地址,之后的数据读写都是以它作为基准
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        //pa为false的情况,address==base
        address = base;
    }
    //创建一个Cleaner,将this和一个Deallocator对象传进去
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;

}

В методе построения DirectByteBuffer делается очень много всего, вообще говоря, он разбит на несколько шагов:

  1. предварительно выделенная память
  2. Выделить память
  3. Инициализировать только что выделенное пространство памяти до 0
  4. Создайте объект очистки Функция объекта Cleaner заключается в освобождении соответствующей памяти вне кучи при освобождении объекта DirectByteBuffer.

Схема восстановления памяти вне кучи в Java следующая: когда GC обнаруживает, что объект DirectByteBuffer стал мусором, он вызываетCleaner#cleanОсвобождение соответствующей памяти вне кучи в определенной степени предотвращает утечки памяти. Конечно, вы также можете вызвать этот метод вручную, чтобы заранее освободить память вне кучи.

Реализация очистителя

Давайте взглянемCleaner#cleanРеализация:

public class Cleaner extends PhantomReference<Object> {
   ...
    private Cleaner(Object referent, Runnable thunk) {
        super(referent, dummyQueue);
        this.thunk = thunk;
    }
    public void clean() {
        if (remove(this)) {
            try {
                //thunk是一个Deallocator对象
                this.thunk.run();
            } catch (final Throwable var2) {
              ...
            }

        }
    }
}

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);
        }

    }

Cleaner наследуется от PhantomReference.Чтобы узнать о виртуальных ссылках, вы можете посмотреть, что я писал ранеестатья

Проще говоря, когда референт поля (то есть объект DirectByteBuffer) перерабатывается, он вызываетCleaner#cleanметод, который в итоге будет вызванDeallocator#runВыполните освобождение памяти вне кучи.

Cleaner — это типичный сценарий применения виртуальных ссылок в JDK.

предварительно выделенная память

Затем посмотрите на второй шаг в методе построения DirectByteBuffer,reserveMemory

    static void reserveMemory(long size, int cap) {
        //maxMemory代表最大堆外内存,也就是-XX:MaxDirectMemorySize指定的值
        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }

        //1.如果堆外内存还有空间,则直接返回
        if (tryReserveMemory(size, cap)) {
            return;
        }
		//走到这里说明堆外内存剩余空间已经不足了
        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        //2.堆外内存进行回收,最终会调用到Cleaner#clean的方法。如果目前没有堆外内存可以回收则跳过该循环
        while (jlra.tryHandlePendingReference()) {
            //如果空闲的内存足够了,则return
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }

       //3.主动触发一次GC,目的是触发老年代GC
        System.gc();

        //4.重复上面的过程
        boolean interrupted = false;
        try {
            long sleepTime = 1;
            int sleeps = 0;
            while (true) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
                if (sleeps >= MAX_SLEEPS) {
                    break;
                }
                if (!jlra.tryHandlePendingReference()) {
                    try {
                        Thread.sleep(sleepTime);
                        sleepTime <<= 1;
                        sleeps++;
                    } catch (InterruptedException e) {
                        interrupted = true;
                    }
                }
            }

            //5.超出指定的次数后,还是没有足够内存,则抛异常
            throw new OutOfMemoryError("Direct buffer memory");

        } finally {
            if (interrupted) {
                // don't swallow interrupts
                Thread.currentThread().interrupt();
            }
        }
    }
	
    private static boolean tryReserveMemory(long size, int cap) {
        //size和cap主要是page对齐的区别,这里我们把这两个值看作是相等的
        long totalCap;
        //totalCapacity代表通过DirectByteBuffer分配的堆外内存的大小
        //当已分配大小<=还剩下的堆外内存大小时,更新totalCapacity的值返回true
        while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
            if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
                reservedMemory.addAndGet(size);
                count.incrementAndGet();
                return true;
            }
        }
		//堆外内存不足,返回false
        return false;
    }

При создании нового DirecByteBuffer он сначала подтвердит, достаточно ли памяти, если нет, то каким-то образом освободит часть памяти вне кучи, пока доступная память не превысит объем памяти, который необходимо выделить. Конкретные шаги заключаются в следующем:

  1. Если доступной памяти вне кучи достаточно, вернитесь напрямую
  2. перечислитьtryHandlePendingReferenceМетод восстанавливает память вне кучи, соответствующую объекту DirectByteBuffer, который стал мусором, пока не будет достаточно доступной памяти или в настоящее время не будет мусорных объектов DirectByteBuffer.
  3. Основная цель запуска полной сборки мусора — предотвратить «феномен айсберга»: сам объект DirectByteBuffer занимает небольшой объем памяти, но может ссылаться на большой объем памяти вне кучи. Если объект DirectByteBuffer становится мусором после перехода к старому возрасту из-за того, что старый сборщик мусора не был запущен, значит, память вне кучи не была освобождена. Обратите внимание, что если вы используете параметры-XX:+DisableExplicitGC, что System.gc(); недействителен
  4. Повторяйте шаги 1 и 2 до тех пор, пока доступная память не станет больше, чем память, которую необходимо выделить.
  5. Если достаточное количество памяти не восстанавливается после указанного количества раз, OOM

Подробный анализ того, как второй шаг заключается в переработке мусора:tryHandlePendingReferenceПоследний звонокReference#tryHandlePendingметод, передстатьяЭтот метод описан в

static boolean tryHandlePending(boolean waitForNotify) {
        Reference<Object> r;
        Cleaner c;
        try {
            synchronized (lock) {
                //pending由jvm gc时设置
                if (pending != null) {
                    r = pending;
                    // 如果是cleaner对象,则记录下来
                    c = r instanceof Cleaner ? (Cleaner) r : null;
                    // unlink 'r' from 'pending' chain
                    pending = r.discovered;
                    r.discovered = null;
                } else {
                    // waitForNotify传入的值为false
                    if (waitForNotify) {
                        lock.wait();
                    }
                    // 如果没有待回收的Reference对象,则返回false
                    return waitForNotify;
                }
            }
        } catch (OutOfMemoryError x) {
            ...
        } catch (InterruptedException x) {
           ...
        }

        // Fast path for cleaners
        if (c != null) {
            //调用clean方法
            c.clean();
            return true;
        }

        ...
        return true;
}

можно увидеть,tryHandlePendingReferenceКонечный эффект таков: если есть мусорный объект DirectBytebuffer, вызовите соответствующийCleaner#cleanспособ утилизации. Чистый метод был проанализирован выше.

Чтение и запись памяти вне кучи

public ByteBuffer put(byte x) {
       unsafe.putByte(ix(nextPutIndex()), ((x)));
       return this;
}

final int nextPutIndex() {                         
    if (position >= limit)
        throw new BufferOverflowException();
    return position++;
}

private long ix(int i) {
    return address + ((long)i << 0);
}

public byte get() {
    return ((unsafe.getByte(ix(nextGetIndex()))));
}

final int nextGetIndex() {                          // package-private
    if (position >= limit)
        throw new BufferUnderflowException();
    return position++;
}

Логика чтения и записи также относительно проста, адрес — это начальный адрес собственной памяти, выделенной в методе построения. Небезопасные методы putByte/getByte — это нативные методы, которые записывают значение по определенному адресу/получают значение по определенному адресу.

Сценарии использования памяти вне кучи

Подходит для долгосрочного существования или сценариев многократного использования

Выделение и переработка памяти вне кучи также имеют накладные расходы, поэтому они подходят для долгоживущих объектов.

Подходит для сцен, где важна стабильность

Память вне кучи может эффективно избежать проблемы паузы, вызванной GC.

Хранилище подходит для простых объектов

Поскольку память вне кучи может хранить только массивы байтов, для сложных объектов DTO требуется сериализация/десериализация для каждого сохранения/чтения.

Подходит для сценариев, ориентированных на эффективность ввода-вывода

Лучшая производительность при чтении и записи файлов с памятью вне кучи

файлIO

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

BIO

БИО файл записатьFileOutputStream#writeВ конечном итоге вызовет собственный слойio_util.c#writeBytesметод

void
writeBytes(JNIEnv *env, jobject this, jbyteArray bytes,
           jint off, jint len, jboolean append, jfieldID fid)
{
    jint n;
    char stackBuf[BUF_SIZE];
    char *buf = NULL;
    FD fd;

 	...

    // 如果写入长度为0,直接返回0
    if (len == 0) {
        return;
    } else if (len > BUF_SIZE) {
        // 如果写入长度大于BUF_SIZE(8192),无法使用栈空间buffer
        // 需要调用malloc在堆空间申请buffer
        buf = malloc(len);
        if (buf == NULL) {
            JNU_ThrowOutOfMemoryError(env, NULL);
            return;
        }
    } else {
        buf = stackBuf;
    }

    // 复制Java传入的byte数组数据到C空间的buffer中
    (*env)->GetByteArrayRegion(env, bytes, off, len, (jbyte *)buf);
 	
     if (!(*env)->ExceptionOccurred(env)) {
        off = 0;
        while (len > 0) {
            fd = GET_FD(this, fid);
            if (fd == -1) {
                JNU_ThrowIOException(env, "Stream Closed");
                break;
            }
            //写入到文件,这里传递的数组是我们新创建的buf
            if (append == JNI_TRUE) {
                n = (jint)IO_Append(fd, buf+off, len);
            } else {
                n = (jint)IO_Write(fd, buf+off, len);
            }
            if (n == JVM_IO_ERR) {
                JNU_ThrowIOExceptionWithLastError(env, "Write error");
                break;
            } else if (n == JVM_IO_INTR) {
                JNU_ThrowByName(env, "java/io/InterruptedIOException", NULL);
                break;
            }
            off += n;
            len -= n;
        }
    }
}

GetByteArrayRegionПо сути, это копия массива, реализация этой функции есть в макроопределении jni.cpp, и найти ее пришлось очень долго.

//jni.cpp
JNI_ENTRY(void, \
jni_Get##Result##ArrayRegion(JNIEnv *env, ElementType##Array array, jsize start, \
             jsize len, ElementType *buf)) \
 ...
      int sc = TypeArrayKlass::cast(src->klass())->log2_element_size(); \
      //内存拷贝
      memcpy((u_char*) buf, \
             (u_char*) src->Tag##_at_addr(start), \
             len << sc);                          \
...
  } \
JNI_END

Можно видеть, что традиционный BIO, прежде чем собственный уровень фактически запишет файл, скопирует массив байтов в память вне кучи (память, выделенную c), а затем использует массив вне кучи для реального ввода-вывода. Причина этого

1.底层通过write、read、pwrite,pread函数进行系统调用时,需要传入buffer的起始地址和buffer count作为参数。如果使用java heap的话,我们知道jvm中buffer往往以byte[] 的形式存在,这是一个特殊的对象,由于java heap GC的存在,这里对象在堆中的位置往往会发生移动,移动后我们传入系统函数的地址参数就不是真正的buffer地址了,这样的话无论读写都会发生出错。而C Heap仅仅受Full GC的影响,相对来说地址稳定。

2.JVM规范中没有要求Java的byte[]必须是连续的内存空间,它往往受宿主语言的类型约束;而C Heap中我们分配的虚拟地址空间是可以连续的,而上述的系统调用要求我们使用连续的地址空间作为buffer。

Приведенное выше содержание взято из ответа Zhihu ETIN.Ууху. Call.com/question/60…

То же самое относится и к чтению файла BIO, которое здесь не будет анализироваться.

NIO

Запись файла NIO в конечном итоге вызоветIOUtil#write

 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);
        //创建一块堆外内存,并将数据赋值到堆外内存中去
        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);

            int n = writeFromNativeBuffer(fd, bb, position, nd, lock);
            if (n > 0) {
                // now update src
                src.position(pos + n);
            }
            return n;
        } finally {
            Util.offerFirstTemporaryDirectBuffer(bb);
        }
    }
    
 	/**
     * 分配一片堆外内存
     */
    static ByteBuffer getTemporaryDirectBuffer(int size) {
        BufferCache cache = bufferCache.get();
        ByteBuffer buf = cache.get(size);
        if (buf != null) {
            return buf;
        } else {
            // No suitable buffer in the cache so we need to allocate a new
            // one. To avoid the cache growing then we remove the first
            // buffer from the cache and free it.
            if (!cache.isEmpty()) {
                buf = cache.removeFirst();
                free(buf);
            }
            return ByteBuffer.allocateDirect(size);
        }
    }

   

Видно, что запись файла NIO также будет иметь дополнительную копию памяти для памяти в куче.

End

На этом анализ памяти вне кучи заканчивается Основная причина, по которой JVM так много обрабатывает память вне кучи, заключается в том, что Java не является таким языком, как C, который полностью управляет памятью разработчиками. Следовательно, даже если используется память вне кучи, JVM надеется автоматически освобождать память вне кучи, когда это необходимо.