@堆外内存和堆内内存及虚引用的应用

目录

内存区域划分:

元空间

程序计数器

直接内存

对象的创建

对象的访问定位

判断对象是否存活

堆外内存

堆内内存的缺点以及引入堆外内存

为什么需要堆外内存?

如何分配堆外内存?

如何回收堆外内存?

1) System.gc() 触发

在预定内存时,为什么要主动调用System.gc

2) Cleaner 对象

为什么要使用堆外内存

为什么不能大面积使用堆外内存

巨人的肩膀


在介绍堆内内存之前首先来看一下JDK内存的划分,以便于更好的理解堆内内存的作用

内存区域划分:

JDK 1.8 之前的 JVM 运行时数据区域分布图:

 JDK 1.8及之后的 JVM 运行时数据区域分布图: 

通过 JDK 1.8 之前与 JDK 1.8 之后的 JVM 运行时数据区域分布图对比,我们可以发现区别就是 1.8有一个元空间替代方法区。下文元空间章节介绍了为何替换方法区

元空间

JDK 1.8就把方法区改用元空间了。类的元信息被存储在元空间中,元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大。

JDK 1.8 中 HotSpot JVM 移出 永久代(PermGen),开始时使用元空间(Metaspace)。使用元空间取代永久代的实现的主要原因如下:

  1. 避免OOM异常,字符串存在永久代中,容易出现性能问题和内存溢出;
  2. 永久代设置空间大小是很难确定,太小容易出现永久代溢出,太大则容易导致老年代溢出;
  3. 永久代进行调优非常困难;
  4. 将 HotSpot 与 JRockit 合二为一;

程序计数器

Program Counter Register:一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。由于 JVM 可以并发执行线程,所以会为每个线程分配一个程序计数器,与线程的生命周期相同。因此会存在线程之间的切换,而这个时候就程序计数器会记录下当前程序执行到的位置,以便在其他线程执行完毕后,恢复现场继续执行。

如果线程正在执行的是 Java 方法,这个计数器记录的是正在执行虚拟机字节码指令的地址;如果正在执行的是 Native 方法,计数器的值则为空(undefined)

此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。

JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

对象的创建

下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。

①类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

②分配内存:类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存分配的两种方式:(补充内容,需要掌握)

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。

内存分配并发问题(补充内容,需要掌握)

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配

③初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

④设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

⑤执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的访问定位

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄②直接指针两种:

  1. 句柄: 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

  2. 直接指针: 如果使用直接指针访问,那么 Java 堆对像的布局中就必须考虑如何防止访问类型数据的相关信息,reference 中存储的直接就是对象的地址。

    这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

堆外内存

平时编程时,在 Java 中创建对象,实际上是在堆上划分了一块区域,这个区域叫堆内内存

  • 使用这 -Xms -Xmx 来指定新生代老年代空间大小的初始值和最大值,这初始值和最大值也被称为 Java的大小,即 堆内内存大小。
  • 这个堆内内存完全受 JVM 管理JVM 有垃圾回收机制,所以我们一般不必关系对象的内存如何回收。

剖开 JVM 内存模型,来看下其堆划分:

 由图可知 Java8 使用元空间替代永久代且元空间放在堆外内存上,这是为啥?

  1. 类的元数据信息常用到,在 GC 时回收,效率偏低。
  2. 类的元数据信息比较难以确定其大小,指定太小容易出现永久代溢出、指定太大则容易造成老年代溢出。

堆内内存的缺点以及引入堆外内存

Java中的对象都是在JVM堆中分配的,其好处在于开发者不用关心对象的回收。但有利必有弊,堆内内存主要有两个缺点:1.GC是有成本的,堆中的对象数量越多,GC的开销也会越大。2.使用堆内内存进行文件、网络的IO时,JVM会使用堆外内存做一次额外的中转,也就是会多一次内存拷贝。

和堆内内存相对应,堆外内存就是把内存对象分配在Java虚拟机堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。

Java 程序一般使用 -XX:MaxDirectMemorySize 来限制最大堆外内存

还有个问题:堆外内存属于用户空间还是内核空间? 用户空间。

为什么需要堆外内存?

使用堆外内存,有这些好处:

  1. 直接使用堆外内存可以减少一次内存拷贝: 当进行网络 I/O 操作、文件读写时,堆内内存都需要转换为堆外内存,然后再与底层设备进行交互。
  2. 降低 JVM GC 开销对应用程序影响:因为堆外内存不受 JVM 管理。
  3. 堆外内存可以实现进程之间、JVM 多实例之间的数据共享。

为什么使用堆外内存可以减少一次内存拷贝呢?

原因:当进行网络 I/O操作或文件读写时,如果使用堆内内存(HeapByteBufferJDK 会先创建一个堆外内存(DirectBuffer,再去执行真正的读写操作。

具体原因是:调用底层系统函数(writeread等),必须要求使用是连续的地址空间

  1. 操作系统并不感知 JVM 的堆内存,而且 JVM 的内存布局与操作系统所分配的是不一样的,操作系统并不会按照 JVM 的行为来读写数据。
  2. 同一个对象的内存地址随着 JVM GC 的执行可能会随时发生变化,例如 JVM GC 的过程中会通过压缩来减少内存碎片,这就涉及对象移动的问题了。

当然使用堆外内存,有这些弊端:

  1. 排查内存泄漏问题相对困难: 因为堆外内存需要手动释放,不熟悉对应框架源码,可能稍有不慎就会造成应用程序内存泄漏。
  2. 对开发人员的基础技能要求高。

由此可以看出,如果想实现高效的 I/O 操作、缓存常用的对象、降低 JVM GC 压力,堆外内存是一个非常不错的选择。

如何分配堆外内存?

查看Buffer 的方法,定义了 isDirect() 方法,返回当前 Buffer 是否是 Direct 类型。这是因为 Java 提供了堆内和堆外(Direct)Buffer,可以以它的 allocate 或者 allocateDirect 方法直接创建。

Java中分配堆外内存首先通过ByteBuffer.java.allocateDirect()得到以一个DirectByteBuffer对象;其中DirectByteBuffer是用Unsafe去实现内存分配的,对堆内存的分配、读写、回收都做了封装。

首先来看下 Java NIO 包中的 ByteBuffer 类的分配方式,使用方式如下:

//分配10M堆外内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
//释放堆外内存
((DirectBuffer) byteBuffer).cleaner().clean();

跟进 ByteBuffer.allocateDirect 源码,发现其中直接调用的 DirectByteBuffer 构造函数:

DirectByteBuffer(int cap) {//注意它是一个构造方法 
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);//注意这里会调用 System.gc();

    long base = 0;
    try {
        //1. 真正分配堆外内存
        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;
    }
    //2. 用于回收堆外内存
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

DirectByteBuffer构造方法中还做了挺多事情的,总的来说分为几个步骤:

  1. 预分配内存
  2. 分配内存
  3. 将刚分配的内存空间初始化为0
  4. 创建一个cleaner对象,Cleaner对象的作用是当DirectByteBuffer对象被回收时,释放其对应的堆外内存

从DirectByteBuffer的构造方法中可以看出,堆外内存的分配的开始在 Bits.reserveMemory(size, cap);中。

进入Bits类,先看几个和堆外内存相关的成员属性:

maxMemory

用户设置的堆外内存最大分配量,由jvm参数-XX:MaxDirectMemorySize= 配置。

reservedMemory

已使用堆外内存的大小。使用AtomicLong来保证多线程下的安全性。

totalCapacity

总容量。同样使用AtomicLong。

count

记录分配堆外内存的总份数。

memoryLimitSet

一个标记变量,有volatile关键字。用来记录maxMemory字段是否已初始化。

在分配堆外内存前,jdk使用tryReserveMemory方法实现了一个乐观锁,来保证实际分配的堆外内存总数不会大于设计的上限。

在tryReserveMemory中的逻辑也比较简单,使用while循环+CAS来保证有足够的剩余空间,并更新总空间,剩余空间,和堆外内存数。可以看出,如果CAS失败,但还有足够的容量,while循环会进入下一轮CAS更新尝试,直到更新成功或容量不足。

在reserveMemory方法中,只是先将堆外内存相关的属性设值,但并没有真正的分配内存。

Java的堆外内存回收设计是这样的:当GC发现DirectByteBuffer对象变成垃圾时,会调用Cleaner.clean()回收对应的堆外内存,一定程度上防止了内存泄露。当然,也可以手动的调用该方法,对堆外内存进行提前回收。

DirectByteBuffer 对象: 存放在堆内存里,仅仅包含堆外内存的地址、大小等属性。同时还会创建对应的 Cleaner 对象,通过 ByteBuffer 分配的堆外内存不需要手动回收,它可以被 JVM 自动回收;当然,也可以手动的调用该方法,对堆外内存进行提前回收。

当堆内的 DirectByteBuffer 对象被 GC 回收时,Cleaner 就会用于回收对应的堆外内存。 

真正分配堆外内存的逻辑还是通过 unsafe.allocateMemory(size)        

Unsafe 是一个非常不安全的类,它用于执行内存访问、分配、修改等敏感操作,可以越过 JVM 限制的枷锁。Unsafe 最初并不是为开发者设计的,使用它时虽然可以获取对底层资源的控制权,但也失去了安全性的保证,所以使用 Unsafe 一定要慎重。

private static Unsafe unsafe = null;
static {
    try {
        Field getUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        getUnsafe.setAccessible(true);
        unsafe = (Unsafe) getUnsafe.get(null);
    } catch (NoSuchFieldException | IllegalAccessException e) {
        e.printStackTrace();
    }
}

获得 Unsafe 实例后,可以通过 allocateMemory 方法分配堆外内存,allocateMemory 方法返回的是内存地址,使用方法如下所示:

//分配 10M 堆外内存
long address = unsafe.allocateMemory(10 * 1024 * 1024);
//Unsafe#allocateMemory 所分配的内存必须自己手动释放, 否则会造成内存泄漏
//这也是 Unsafe 不安全的体现。
unsafe.freeMemory(address);

在实际使用中,Java 会尽量对 Direct Buffer 仅做本地 IO 操作,对于很多大数据量的 IO 密集操作,可能会带来非常大的性能优势,因为:

  1. Direct Buffer 生命周期内内存地址都不会再发生更改,进而内核可以安全地对其进行访问,很多 IO 操作会很高效。

  2. 减少了堆内对象存储的可能额外维护工作,所以访问效率可能有所提高。

如何回收堆外内存?

堆外内存回收,有两种方式:

  1. Full GC 时以及调用 System.gc() 通过 JVM 参数 -XX:MaxDirectMemorySize 指定堆外内存的上限大小,当堆外内存的大小超过该阈值时,就会触发一次 Full GC 进行清理回收,如果在 Full GC 之后还是无法满足堆外内存的分配,那么程序将会抛出 OOM 异常。

  2. 使用unsafe.freeMemory(address); 来回收: DirectByteBuffer 在初始化时会创建一个 Cleaner 对象Cleaner 内同时会创建 Deallocator,调用 Deallocator#run() 来回收。

1) System.gc() 触发

ByteBuffer.allocateDirect 分配的过程中: 如果没有足够的空间分配堆外内存,在 Bits.reserveMemory 方法中也会主动调用 System.gc() ,就会触发 Full GC(并不是马上执行)。 

// ByteBuffer.allocateDirect 直接调用 DirectByteBuffer 构造函数
DirectByteBuffer(int cap) { 
    ... 
    Bits.reserveMemory(size, cap);// 注意这个方法里会调用System.gc();
    ...
}

注意: 如果环境中设置了 -XX:+DisableExplicitGCSystem.gc() 会不起作用的。

所以依赖 System.gc() 并不是一个好办法。

堆外内存的回收

既然要调用System.gc,那肯定是想通过触发一次gc操作来回收堆外内存,不过堆外内存不会对gc造成什么影响(这里的System.gc除外),堆外内存的回收和GC是必须有关的;既然和GC也有关,那就是gc能通过操作DirectByteBuffer对象来间接操作对应的堆外内存了。DirectByteBuffer对象在创建的时候创建了一个Cleaner对象,它是PhantomReference的子类。PhantomReference其实主要是用来跟踪对象何时被回收的,它不能影响gc决策,但是gc过程中如果发现某个对象除了只有PhantomReference引用它之外,并没有其他的地方引用它了,那将会把这个引用放到Reference队列里,在gc完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理,在最终的处理里会通过native库的Unsafe的free接口来释放DirectByteBuffer对应的堆外内存块。

2) Cleaner 对象

通过前面堆外内存分配方式的介绍,我们知道 DirectByteBuffer 在初始化时会创建一个 Cleaner 对象,它会负责堆外内存的回收工作,那么 Cleaner 是如何与 GC 关联起来的呢?

先来看下 Cleaner 的源码:

可以看到 Cleaner 属于 PhantomReference 的子类,那 Cleaner#clean() 执行是否跟 JVM GC 或Reference 有关呢? 

TipsJava 对象有四种引用方式, 强引用 StrongReference、软引用 SoftReference、弱引用 WeakReference、虚引用 PhantomReference

这里先了解下 Reference 核心处理流程:

  1. JVM 垃圾收集器扫描到对象 O 可回收。

  2. 把对象 O 对应的 Reference 实例 R 添加到 PendingReference 链表中。

  3. 通知 ReferenceHandler 线程处理,最后完成清理逻辑。

private static class ReferenceHandler extends Thread {
        private static void ensureClassInitialized(Class<?> clazz) {
            try {
                Class.forName(clazz.getName(), true, clazz.getClassLoader());
            } catch (ClassNotFoundException e) {
                throw (Error) new NoClassDefFoundError(e.getMessage()).initCause(e);
            }
        }
        static {
            ensureClassInitialized(InterruptedException.class);
            ensureClassInitialized(Cleaner.class);
        }
        ReferenceHandler(ThreadGroup g, String name) {
            super(g, name);
        }
        public void run() {
            while (true) {
                tryHandlePending(true);
            }
        }
    }

    static boolean tryHandlePending(boolean waitForNotify) {
        Reference<Object> r;
        Cleaner c;
        try {
            synchronized (lock) {
                if (pending != null) {
                    r = pending; 
                    //判断是否为 Cleaner
                    c = r instanceof Cleaner ? (Cleaner) r : null;
                    pending = r.discovered;
                    r.discovered = null;
                } else {
                    if (waitForNotify) {
                        lock.wait();
                    }
                    return waitForNotify;
                }
            }
        } catch (OutOfMemoryError x) {
            Thread.yield();
            return true;
        } catch (InterruptedException x) {
            return true;
        }
        if (c != null) {//是Cleaner, 则调用Cleaner.clean()方法
            c.clean();
            return true;
        }
        ReferenceQueue<? super Object> q = r.queue;
        if (q != ReferenceQueue.NULL) q.enqueue(r);
        return true;
    }

    static {
        ThreadGroup tg = Thread.currentThread().getThreadGroup();
        for (ThreadGroup tgn = tg;
             tgn != null;
             tg = tgn, tgn = tg.getParent());
        Thread handler = new ReferenceHandler(tg, "Reference Handler");
        handler.setPriority(Thread.MAX_PRIORITY);
        handler.setDaemon(true);
        handler.start();
        // provide access in SharedSecrets
        SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
            @Override
            public boolean tryHandlePendingReference() {
                return tryHandlePending(false);
            }
        });
    }

总结一下: 当 DirectByteBuffer 被回收的时候,会调用 Cleaner 的 clean() 方法来释放堆外内存。

对于 Direct Buffer 的回收,有几个建议:

  1. 在应用程序中,显式地调用 System.gc() 来强制触发。

  2. 另外一种思路是,在大量使用 Direct Buffer 的部分框架中,框架会自己在程序中调用释放方法,Netty 就是这么做的,有兴趣可以参考其实现(PlatformDependent0)。

  3. 重复使用 Direct Buffer。

为什么要使用堆外内存

DirectByteBuffer在创建的时候会通过Unsafe的native方法来直接使用malloc分配一块内存,这块内存是heap之外的,那么自然也不会对gc造成什么影响(System.gc除外),因为gc耗时的操作主要是操作heap之内的对象,对这块内存的操作也是直接通过Unsafe的native方法来操作的,相当于DirectByteBuffer仅仅是一个壳,还有我们通信过程中如果数据是在Heap里的,最终也还是会copy一份到堆外,然后再进行发送,所以为什么不直接使用堆外内存呢。对于需要频繁操作的内存,并且仅仅是临时存在一会的,都建议使用堆外内存,并且做成缓冲池,不断循环利用这块内存。

为什么不能大面积使用堆外内存

如果我们大面积使用堆外内存并且没有限制,那迟早会导致内存溢出,毕竟程序是跑在一台资源受限的机器上,因为这块内存的回收不是你直接能控制的,当然你可以通过别的一些途径,比如反射,直接使用Unsafe接口等,但是这些务必给你带来了一些烦恼,Java与生俱来的优势被你完全抛弃了—开发不需要关注内存的回收,由gc算法自动去实现。另外上面的gc机制与堆外内存的关系也说了,如果一直触发不了cms gc或者full gc,那么后果可能很严重。

巨人的肩膀

一文搞懂堆外内存(模拟内存泄漏) - 掘金---不错

关于JVM堆外内存的一切 - 掘金

Spring Boot引起的“堆外内存泄漏”排查及经验总结 - 掘金---未学习

彻底弄懂零拷贝、MMAP、堆外内存 - 掘金---偏向于操作系统(未学习)

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值