JVM内存布局与 JNA 调用本地方法原理详解

JVM 内存布局与 JNA 调用本地方法原理详解

JVM 内存布局详解

JVM 内存布局随着 JDK 版本不同而不同, 但是大致布局以及运行原理相同, 我们选择 JDK1.8 的内存布局解释.
下图是JDK 1.8 的内存布局的示意图:

img

程序计数器(PC)

这个是当前线程正在执行的字节码行号指示器, 类似于实际的PC, 根据这里面的内存数据来确定程序接下来执行的指令. 在JAVA中, 每个线程都有一个, 相互隔离, 线程之间的切换就是基于程序计数器. 如果执行的是方法, 这里记录的是虚拟机字节码指令的地址. 注意:当执行的是Native方法的时候为空(Undefined).
因为只存储一个指令, 所以它不会出现任何 OutOfMemoryError.

Java虚拟机栈

每个线程私有, 里面装的多个栈帧, 每个栈帧对于的一个方法. 里面存储的是Java方法的内存模型. 相当于描述的是一个方法需要的内容. 逻辑上类似于操作系统中的进程栈, 每个线程都有一个虚拟机栈, 每个栈中都有多个栈帧. 每个方法的执行过程都是栈帧的进栈于出栈, 类似于进程中的函数调用.

每个栈帧存就是对方法的描述, 栈帧中存储局部变量, 局部变量是一个方法内使用的变量, 包括各种数据类型的临时变量. (boolean、byte、char、short、int、float、long、double 类型), 以及对象的引用. 对象本身并不会存储在线程栈中, 即使这个对象是在方法中新建的, 线程栈中也仅会保存对象的引用, 作为局部变量, 而对象本身则存储在JVM的堆空间中.

异常情况:线程请求的栈深度大于虚拟机允许的深度, 将抛出StackOverflowError异常. 如果虚拟机栈可以动态扩展, 当扩展的时候没有申请到内存的时候抛出OutOfMemoryError.

本地方法栈

每个线程都有自己的本地方法栈, 这时线程私有的, 用于线程支持对本地方法的调用, 本地方法栈(Native Method Stack)是为调用本地方法(Native Method)服务的, 一个典型的例子是使用 JNI(Java Native Interface)调用用 C/C++ 编写的本地代码. 本地方法通常用于调用操作系统的 API, 使用已有的 C++ 库, 与硬件交互等, 本地方法栈也就是本地方法代码执行的地方.

Java堆

Java虚拟机管理最大的一块, 线程共享, 存放对象实例和数组.分新生代(1/3)和老年代(2/3), 新生代还可以分Eden(8/10)、From Survivor(1/10) 、To Survivor(1/10), 是主要根据垃圾清理来分的.

异常情况: 无法再对对象实例分配, 并且堆也无法扩展时, 将抛出OutOfMemoryError.

方法区

线程共享, 主要存储被虚拟机加载的类信息, 常量, 静态变量, 即时编译器编译后的代码. 运行时常量池也是方法区的一部分, 比如String有一个常量池, 他就是放到这个里面的. 类似于进程的代码区以及全局变量区.

异常情况: 当方法区无法满足内存分配时, 将抛出OutOfMemoryError异常.

直接内存

NIO通过使用Native函数库直接分配对外内存, 因为JVM本质上是一个进程, 这部分内存实际上是 JVM 进程的堆内存, java 程序可以在内部申请这块内存使用, 这种使用方式相当于绕过了 JVM 的内存管理与自动回收机制, 内存管理需要开发者手动管理. Java 支持两种方法使用这块内存, 分别是 JVM 自身提供的接口, 这块内存直接被 Java 代码使用, 开发者可以自由的使用申请到的堆外内存. 另一种是 Native 代码申请内存, 这部分内存用于 JNA 调用本地代码的时候使用. 这两种使用方式的相同点是, 两者均使用的是 JVM 这个进程的用户的虚拟空间的堆的内存空间, 不同点是:

特性 堆外内存(Off-Heap Memory) Native 代码申请的内存
分配方式 使用 JVM 提供的接口(如 Unsafe.allocateMemoryDirectByteBuffer). 通过本地代码(C/C++)直接调用操作系统 API(如 malloc).
管理接口 Java 层提供操作接口, UnsafeDirectByteBuffer 包装了分配的内存. 完全依赖 C/C++ 的内存管理工具(如指针操作).
语言依赖 完全在 Java 中操作, 使用 Java 提供的工具类或方法来访问和管理内存. 必须通过 JNI 或其他桥接机制从 Java 调用 C/C++ 函数.
释放机制 DirectByteBuffer 可依赖 JVM 的 Cleaner 机制自动释放(但不及时). 由 C/C++ 开发者显式调用 freedelete 来释放内存.
线程安全性 Java 层可能提供一些线程安全特性(如 ByteBuffer 的读写同步). 完全依赖 C/C++ 代码的实现.
调试和诊断工具支持 JVM 提供了一些工具(如 jmap)可以查看堆外内存的使用情况. 必须依赖 C/C++ 的调试工具(如 valgrind).

异常情况: 不受Java堆大小限制, 但是受机器的物理内存限制, 当各个内存区域大于机器物理内存的时候, 会出现OutOfMemoryError.

JNA 调用本地方法详解

首先我们需要明白什么是本地方法:

本地方法

本地方法(Native Method)是指使用非 Java 语言(通常是 C 或 C++)编写的函数, 通过 Java 提供的接口在 JVM 中调用这些方法. "本地"指的是些方法与当前运行环境(操作系统和硬件)紧密关联, 直接使用底层的系统资源或外部库, 而不依赖 JVM 本身的实现. 本地方法用 native 关键字在 Java 中声明, 但实现部分由其他语言完成, 通常通过动态链接库(如 .so 或 .dll 文件)提供.

本地方法的用途

由于本地方法可以跳出 JVM 与其他进程交互, 有下列的主要用途:

  1. 调用操作系统功能:
    Java 无法直接访问的系统级资源(如文件描述符、网络接口、设备驱动)需要通过本地方法来操作. 示例: 通过本地方法实现文件锁定、访问系统进程信息等.
  2. 性能优化:
    对于高性能需求的场景, 例如图像处理, 数据压缩等, 可以通过本地方法调用更高效的 C/C++ 实现.
  3. 调用已有的动态库:
    如果已有功能以 C/C++ 的形式提供, 而不希望重新用 Java 实现, 可以通过本地方法直接调用现有的动态链接库.

Java 本地方法调用的方式

Java 使用 JNI (Java Native Interface) 作为调用本地方法的接口, 调用过程如下图所示:
img

JNI 是由 JVM 提供的一个原生接口, 用于让 Java 调用本地代码(如 C/C++ 编写的动态链接库).它通过手动编写桥接层, 完成 Java 与底层代码之间的绑定与交互.
这种机制虽然功能强大, 但需要开发者编写额外的头文件及绑定代码, 增加了复杂性.

JNA (Java Native Access) 基于 JNI 实现, 提供了更高级别且简单的 API, 用于调用本地代码.与 JNI 不同, JNA 不需要手动编写桥接层, 而是通过动态代理和反射直接调用本地函数.

JNA 的主要特点是无需编写桥接代码, 其基本流程如下:


C 代码实现

#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

编译生成动态链接库:

gcc -shared -o libnative.so -fPIC native.c

Java 中调用动态链接库

通过 JNA 调用动态链接库, 不需要写头文件或桥接代码, 直接调用即可:

import com.sun.jna.Library;
import com.sun.jna.Native;

public class JNADemo {

    // 定义接口, 继承 Library
    public interface NativeLibrary extends Library {
        NativeLibrary INSTANCE = Native.load("native", NativeLibrary.class); // 加载动态库

        int add(int a, int b); // 定义本地方法
    }

    public static void main(String[] args) {
        int result = NativeLibrary.INSTANCE.add(5, 3); // 调用本地方法
        System.out.println("Result: " + result);
    }
}

JNA 本地方法调用的原理

JNA 的工作流程看似简洁, 但背后的运行机制涉及 JVM 如何加载和调用外部程序.我们可以从 代码加载执行过程 两个角度分析其原理.


动态链接库的加载位置

JVM 本质上是一个进程.Java 程序调用动态链接库的过程实质上是 JVM 进程加载和调用动态链接库.这与 C 程序调用动态链接库类似:
动态链接库的代码会被加载到 JVM 进程的虚拟地址空间中的动态链接库代码区(Code Segment).在操作系统中, 每个程序的虚拟地址空间都有自己的代码区, 用于存储加载的动态链接库.


本地方法的执行方式

当线程调用动态链接库中的函数时, 其执行方式与调用 Java 方法类似, 都是通过 程序计数器栈调用 完成.

区别在于:

  • 调用 Java 方法时, 线程使用的是 JVM 的 Java 方法栈.
  • 调用本地方法时, 线程使用的是每个线程私有的 本地方法栈.

本地方法的内存操作

在执行本地方法时, 涉及以下两部分内存:

  1. 本地方法栈:
    本地方法中的局部变量和调用信息存储在本地方法栈中.这是线程私有的, 作用类似于 JVM 的线程栈.
  2. 堆外内存:
    动态链接库中可能会通过 malloc 动态分配内存, 或者通过指针操作访问额外的内存空间.这些操作会申请 JVM 堆外的内存区域(Off-Heap Memory).

堆外内存的分配和释放由动态链接库自行管理, 与 JVM 的垃圾回收器无关.这是因为动态链接库与 JVM 是独立的运行单元, 其内存操作直接基于操作系统提供的用户态内存管理(如 brkmmap).

本质上, 动态链接库申请的堆外内存位于 JVM 进程所属的虚拟地址空间, 由操作系统分配和管理.


总结

  • JNI 是 Java 调用本地代码的原生接口, JNA 基于 JNI 提供了更简化的调用方式.
  • 动态链接库加载到 JVM 进程的动态链接库代码区中, 并通过本地方法栈执行.
  • 本地方法可能涉及 JVM 堆外内存的操作, 需手动管理内存, 避免内存泄漏.
  • JNA 的简化特性使其更适合快速调用外部动态链接库, 但在性能上可能略逊于 JNI.

posted @ 2024-12-27 11:23  虾野百鹤  阅读(272)  评论(0)    收藏  举报