Ponte para o Nativo — a Foreign Function & Memory API

Ponte para o Nativo — a Foreign Function & Memory API

Como chamar funções em C a partir do Java com a Foreign Function & Memory API: Linker, SymbolLookup e FunctionDescriptor, uma biblioteca própria compilada com gcc, structs com MemoryLayout, upcalls com a qsort, arenas que liberam a memória nativa e os erros que a API transforma em exceções.
Java

• • 19 min de leitura

Como um programa Java chama funções escritas em C e manipula memória fora do heap, com a API moderna que substituiu o JNI, e o que ela preserva da segurança da JVM.

"Medir Antes de Otimizar" terminou lembrando que nem todo código útil é escrito em Java. As bibliotecas do sistema operacional, os motores de bancos de dados embutidos, as bibliotecas de criptografia, de compressão, de processamento de imagem e de aprendizado de máquina são, em grande parte, escritas em C e C++. Durante quase trinta anos, a forma de chamá-las a partir do Java foi o JNI (Java Native Interface), trabalhoso e perigoso. Desde o Java 22, a plataforma tem uma API definitiva para isso, a Foreign Function & Memory API (FFM). Nesta aula você vai chamar funções da biblioteca padrão do C, compilar uma biblioteca própria em C e usá-la a partir do Java, trocar estruturas entre as duas linguagens, fazer o C chamar código Java, e usar memória fora do heap com segurança. Com ela, termina a Fase 6.

O problema e a solução antiga

A JVM é um mundo fechado e seguro: memória gerenciada, verificação de limites, nenhum ponteiro solto. O código em C vive fora desse mundo, com ponteiros, memória alocada à mão e nenhuma verificação. Para atravessar essa fronteira, o JNI exigia, para cada função nativa:

  • declarar um método native numa classe Java;
  • gerar um arquivo de cabeçalho C a partir dela;
  • escrever código C de ligação, que convertia os tipos Java em tipos C e vice-versa, usando uma API própria;
  • compilar esse código C separadamente para cada sistema operacional e processador.

Qualquer erro no código de ligação, como um ponteiro inválido ou uma referência esquecida, podia derrubar a JVM inteira com uma falha de segmentação, sem exceção nem stack trace. Por isso, o JNI sempre foi evitado sempre que possível.

A FFM elimina o código de ligação: tudo é escrito em Java. Ela tem duas partes. A primeira chama funções nativas, que são as funções estrangeiras. A segunda aloca e acessa memória fora do heap, a memória estrangeira, com verificação de limites e de tempo de vida.

A primeira chamada: strlen

A função strlen, da biblioteca padrão do C, conta os bytes de um texto terminado em zero. Para chamá-la:

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

public class Strlen {
    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();
        SymbolLookup libc = linker.defaultLookup();

        MethodHandle strlen = linker.downcallHandle(
            libc.find("strlen").orElseThrow(),
            FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment texto = arena.allocateFrom("Olá, mundo nativo!");
            long tamanho = (long) strlen.invokeExact(texto);
            IO.println("strlen: " + tamanho + " bytes");
            IO.println("length(): " + "Olá, mundo nativo!".length() + " caracteres");
        }
    }
}
strlen: 19 bytes
length(): 18 caracteres

As peças, uma por uma:

  • Linker conhece a convenção de chamada da plataforma, isto é, como o C da máquina passa argumentos e devolve resultados. É o que torna o mesmo código Java válido no Linux, no Windows e no macOS.
  • SymbolLookup encontra funções pelo nome. defaultLookup() procura nas bibliotecas padrão do sistema, como a libc.
  • FunctionDescriptor descreve a assinatura da função em C: aqui, devolve um inteiro de 64 bits (size_t, representado por JAVA_LONG) e recebe um endereço (const char*, representado por ADDRESS).
  • downcallHandle junta tudo e devolve um MethodHandle, um objeto que representa uma chamada e pode ser executado com invokeExact. Os casts no resultado e os tipos dos argumentos precisam corresponder exatamente ao descritor.
  • Arena controla o tempo de vida da memória nativa. allocateFrom("...") copia o texto Java para fora do heap, em UTF-8 e com o zero final que o C espera. Quando o bloco try-with-resources termina, a arena libera toda a memória alocada nela.

A diferença entre 19 e 18 é a de "Recursos que se Fecham Sozinhos": strlen conta bytes, e o "á" ocupa dois bytes em UTF-8, enquanto length() conta caracteres. Atravessar a fronteira entre as linguagens significa lidar com esse tipo de diferença de representação a todo momento.

O aviso da primeira execução

Rodando sem nenhuma opção, a JVM avisa antes da saída:

WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: java.lang.foreign.Linker::downcallHandle has been called by Strlen in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

Chamar código nativo pode derrubar a JVM, porque o código C não tem nenhuma das proteções do Java, e a plataforma trata isso como um poder que precisa ser concedido explicitamente. --enable-native-access=ALL-UNNAMED concede o acesso ao código no classpath. Num projeto modular, concede-se só ao módulo que precisa, pelo nome, e o próprio JAR pode declarar isso no manifesto. O aviso lembra que, num futuro próximo, o acesso sem permissão será bloqueado. É a mesma filosofia dos módulos de "Fronteiras Explícitas": capacidades perigosas são concedidas de forma explícita e visível.

Uma biblioteca própria em C

Uma biblioteca pequena, estatistica.c:

#include <stddef.h>

double media(const double *valores, int n) {
    if (n <= 0) {
        return 0.0;
    }
    double soma = 0.0;
    for (int i = 0; i < n; i++) {
        soma += valores[i];
    }
    return soma / n;
}

int contar_vogais(const char *texto) {
    int total = 0;
    for (const char *p = texto; *p != '\0'; p++) {
        switch (*p) {
            case 'a': case 'e': case 'i': case 'o': case 'u':
            case 'A': case 'E': case 'I': case 'O': case 'U':
                total++;
        }
    }
    return total;
}

Compilada como biblioteca compartilhada, no Linux (no macOS a extensão é .dylib, e no Windows, .dll):

gcc -shared -fPIC -O2 -o libestatistica.so estatistica.c

E usada pelo Java:

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.nio.file.Path;

import static java.lang.foreign.ValueLayout.*;

public class Estatistica {
    private static final Linker LINKER = Linker.nativeLinker();
    private static final SymbolLookup BIBLIOTECA =
        SymbolLookup.libraryLookup(Path.of("./libestatistica.so"), Arena.global());

    private static final MethodHandle MEDIA = LINKER.downcallHandle(
        BIBLIOTECA.find("media").orElseThrow(),
        FunctionDescriptor.of(JAVA_DOUBLE, ADDRESS, JAVA_INT));

    private static final MethodHandle CONTAR_VOGAIS = LINKER.downcallHandle(
        BIBLIOTECA.find("contar_vogais").orElseThrow(),
        FunctionDescriptor.of(JAVA_INT, ADDRESS));

    static double media(double[] valores) {
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment nativo = arena.allocateFrom(JAVA_DOUBLE, valores);
            return (double) MEDIA.invokeExact(nativo, valores.length);
        } catch (Throwable t) {
            throw new IllegalStateException("Falha ao chamar media()", t);
        }
    }

    static int contarVogais(String texto) {
        try (Arena arena = Arena.ofConfined()) {
            return (int) CONTAR_VOGAIS.invokeExact(arena.allocateFrom(texto));
        } catch (Throwable t) {
            throw new IllegalStateException("Falha ao chamar contar_vogais()", t);
        }
    }

    public static void main(String[] args) {
        IO.println("média: " + media(new double[] {7.5, 9.0, 4.5, 8.0, 6.5}));
        IO.println("vogais: " + contarVogais("Programação em Java e C"));
    }
}
média: 7.1
vogais: 8

O padrão é o que se usa na prática. Os MethodHandle são criados uma vez, em constantes static final, porque criá-los é caro, e o JIT os otimiza muito bem quando são constantes. Cada função nativa ganha um método Java comum que a embrulha: converte os argumentos, gerencia a memória e trata os erros. O resto do programa chama media(double[]) sem saber que há C por baixo. allocateFrom(JAVA_DOUBLE, valores) copia o array Java para um bloco de memória nativa, que o C enxerga como um double*. A arena global() usada no carregamento da biblioteca vive até o fim do programa, como a própria biblioteca.

Repare que "Programação em Java e C" tem nove vogais, mas o C contou oito. O "ã" não foi contado: em UTF-8 ele ocupa dois bytes, nenhum deles igual a 'a'. A função C foi escrita para textos ASCII, e a fronteira entre as linguagens expôs essa premissa. O exercício 3 volta a isso.

Estruturas: MemoryLayout

Funções C frequentemente trocam structs. Acrescentando à biblioteca:

typedef struct {
    int x;
    int y;
} Ponto;

double distancia_quadrada(Ponto a, Ponto b) {
    double dx = a.x - b.x;
    double dy = a.y - b.y;
    return dx * dx + dy * dy;
}

void mover(Ponto *p, int dx, int dy) {
    p->x += dx;
    p->y += dy;
}

Do lado Java, a estrutura é descrita com um MemoryLayout, e os campos são acessados com VarHandles, que leem e gravam um campo dentro de um bloco de memória:

static final StructLayout PONTO = MemoryLayout.structLayout(
    JAVA_INT.withName("x"),
    JAVA_INT.withName("y"));
static final VarHandle X = PONTO.varHandle(MemoryLayout.PathElement.groupElement("x"));
static final VarHandle Y = PONTO.varHandle(MemoryLayout.PathElement.groupElement("y"));
MethodHandle distancia = linker.downcallHandle(lib.find("distancia_quadrada").orElseThrow(),
    FunctionDescriptor.of(JAVA_DOUBLE, PONTO, PONTO));
MethodHandle mover = linker.downcallHandle(lib.find("mover").orElseThrow(),
    FunctionDescriptor.ofVoid(ADDRESS, JAVA_INT, JAVA_INT));

try (Arena arena = Arena.ofConfined()) {
    MemorySegment a = arena.allocate(PONTO);
    MemorySegment b = arena.allocate(PONTO);
    X.set(a, 0L, 0);  Y.set(a, 0L, 0);
    X.set(b, 0L, 3);  Y.set(b, 0L, 4);

    IO.println("tamanho da struct: " + PONTO.byteSize() + " bytes");
    IO.println("distância²: " + (double) distancia.invokeExact(a, b));

    mover.invokeExact(b, 10, -1);
    IO.println("b depois de mover: (" + (int) X.get(b, 0L) + ", " + (int) Y.get(b, 0L) + ")");
}
tamanho da struct: 8 bytes
distância²: 25.0
b depois de mover: (13, 3)

As duas funções mostram as duas formas de passar uma struct. Em distancia_quadrada, as estruturas vão por valor, e o descritor usa o próprio layout PONTO. Em mover, vai um ponteiro, o descritor usa ADDRESS, e a função C altera a memória, que o Java lê em seguida. O 0L nos acessos é a posição base dentro do segmento. O layout precisa corresponder exatamente à declaração em C, incluindo o alinhamento dos campos, que em estruturas com tipos de tamanhos diferentes insere espaços de preenchimento.

Descrever à mão dezenas de funções e estruturas de um cabeçalho C seria trabalhoso e sujeito a erros. A ferramenta jextract, do projeto OpenJDK, lê os arquivos .h de uma biblioteca e gera automaticamente todas as classes Java de ligação. Para bibliotecas grandes, como SQLite, OpenSSL ou as de processamento de imagem, é o caminho recomendado.

O C chamando o Java

Às vezes a função C precisa chamar código do programa, por exemplo, um comparador. É o caso da qsort, a ordenação da biblioteca padrão do C, que o curso de C deste site usa com ponteiros para função. A FFM permite criar um upcall: um ponteiro de função C que, quando chamado, executa um método Java:

public class Qsort {
    static int comparar(MemorySegment a, MemorySegment b) {
        return Integer.compare(a.get(JAVA_INT, 0), b.get(JAVA_INT, 0));
    }

    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();
        MethodHandle qsort = linker.downcallHandle(
            linker.defaultLookup().find("qsort").orElseThrow(),
            FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS));

        FunctionDescriptor descritorComparador = FunctionDescriptor.of(JAVA_INT,
            ADDRESS.withTargetLayout(JAVA_INT), ADDRESS.withTargetLayout(JAVA_INT));
        MethodHandle comparadorJava = MethodHandles.lookup().findStatic(Qsort.class, "comparar",
            MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class));

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment ponteiroParaComparador =
                linker.upcallStub(comparadorJava, descritorComparador, arena);
            int[] numeros = {42, 7, 19, 3, 25, 11};
            MemorySegment array = arena.allocateFrom(JAVA_INT, numeros);
            qsort.invokeExact(array, (long) numeros.length, JAVA_INT.byteSize(), ponteiroParaComparador);
            IO.println(Arrays.toString(array.toArray(JAVA_INT)));
        }
    }
}
[3, 7, 11, 19, 25, 42]

upcallStub transforma o método Java comparar num ponteiro de função que o C pode chamar. A qsort ordena o array na memória nativa, chamando o comparador Java a cada comparação, e o resultado é copiado de volta para um int[]. withTargetLayout(JAVA_INT) informa que os endereços recebidos apontam para inteiros, e isso permite ler o valor com get(JAVA_INT, 0). É um exemplo didático: ordenar números em Java é muito mais simples com Arrays.sort. Mas o mecanismo é o mesmo que bibliotecas nativas usam para notificar eventos, pedir dados ou chamar funções do programa que as usa.

Memória fora do heap, com segurança

A segunda metade da API é útil mesmo sem nenhum código C. Programas que precisam de grandes blocos de memória, como bancos de dados, caches e servidores de rede, às vezes preferem mantê-los fora do heap, onde o coletor de lixo não precisa examiná-los nem movê-los. Um MemorySegment é um bloco desses, e as arenas controlam quando ele é liberado:

MemorySegment guardado;
try (Arena arena = Arena.ofConfined()) {
    MemorySegment buffer = arena.allocate(JAVA_LONG, 1_000_000);
    for (long i = 0; i < 1_000_000; i++) {
        buffer.setAtIndex(JAVA_LONG, i, i * i);
    }
    IO.println("posição 999: " + buffer.getAtIndex(JAVA_LONG, 999)
               + ", tamanho " + buffer.byteSize() + " bytes");

    try {
        buffer.getAtIndex(JAVA_LONG, 1_000_000);
    } catch (IndexOutOfBoundsException e) {
        IO.println("fora dos limites: " + e.getClass().getSimpleName());
    }
    guardado = buffer;
}

try {
    guardado.getAtIndex(JAVA_LONG, 0);
} catch (IllegalStateException e) {
    IO.println("depois de fechar: " + e.getMessage());
}
posição 999: 998001, tamanho 8000000 bytes
fora dos limites: IndexOutOfBoundsException
depois de fechar: Already closed

É aqui que a FFM mostra a sua diferença para o C e para o JNI. Os dois erros mais perigosos da programação em C, ler fora dos limites e usar memória depois de liberada, geram exceções Java claras, em vez de corromper silenciosamente o programa. Cada segmento conhece o próprio tamanho e a arena a que pertence, e a arena sabe se ainda está aberta.

Os tipos de arena definem quem pode usar a memória e quando ela é liberada:

Arena Liberação Acesso
Arena.ofConfined() ao fechar (close) só pela thread que a criou
Arena.ofShared() ao fechar por qualquer thread
Arena.ofAuto() pelo coletor de lixo, quando não houver mais referências por qualquer thread
Arena.global() nunca por qualquer thread

A confinada, usada nos exemplos, é a mais eficiente e a mais segura, e deve ser a primeira escolha. A restrição de thread da confinada antecipa a fase seguinte: memória compartilhada entre threads é uma fonte de problemas, e a API obriga a declarar explicitamente quando isso é desejado.

Nem tudo é protegido, e é preciso honestidade sobre o limite. O código C que você chama não tem nenhuma das garantias da JVM. Se a função media recebesse um n maior que o tamanho do array, leria lixo além dele, e nenhuma exceção Java aconteceria, porque a leitura é feita pelo C. A FFM protege os acessos feitos pelo Java. As chamadas ao código nativo continuam tão seguras quanto o próprio código nativo, e é por isso que a permissão de acesso nativo existe.

Com esta aula termina a sexta fase. Você viu a JVM por dentro: como carrega e inicializa as classes, como organiza a memória em pilhas, heap e metaspace, como o JIT transforma bytecode em código de máquina otimizado a partir do comportamento real do programa, como os coletores recuperam memória e as trocas entre latência, vazão e memória que cada um faz. Aprendeu a medir com o JFR e o JMH, em vez de adivinhar. E aprendeu a atravessar a fronteira da JVM com a FFM, para chamar código C e usar memória fora do heap sem abrir mão da segurança nas operações feitas pelo Java.

Um detalhe desta aula merece atenção. A arena confinada só pode ser usada pela thread que a criou, e usá-la a partir de outra lança uma exceção. Por que a API se preocupa tanto com isso? Porque, quando duas threads mexem na mesma memória ao mesmo tempo, coisas estranhas acontecem, e elas não acontecem sempre, só às vezes. Quase todos os programas do curso até aqui tiveram uma única thread de execução, e por isso esses problemas ainda não apareceram.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Explique por que strlen devolveu 19 para o texto "Olá, mundo nativo!", enquanto length() devolveu 18. Qual dos dois valores você usaria para reservar um buffer de bytes para esse texto em C, e com qual ajuste?

Ver resposta

✓ Resposta: allocateFrom converte o texto Java para UTF-8, e strlen conta os bytes até o zero final. O "á" ocupa dois bytes em UTF-8, então o texto tem 19 bytes. length() conta as unidades char do String Java, e o "á" é uma única unidade. Para reservar um buffer em C, o que importa são os bytes, e portanto 19, mais um byte para o zero terminador que o C exige: 20 bytes. Usar o length() para isso é um erro clássico, que produz buffers pequenos demais sempre que o texto tem caracteres fora do ASCII, e em C isso significa escrever além do fim do buffer. Na prática, arena.allocateFrom(texto) já calcula o tamanho certo e inclui o terminador, e é por isso que ele deve ser preferido a calcular tamanhos à mão.

Exercício 2

Para cada situação, escolha o tipo de arena e justifique: (a) um buffer temporário usado numa única chamada a uma função C; (b) a memória que guarda uma biblioteca carregada durante toda a execução; (c) um cache fora do heap que várias threads de um servidor leem e que dura enquanto o servidor estiver no ar, mas deve ser liberado num desligamento ordenado; (d) segmentos criados por uma biblioteca, cujo tempo de vida quem a usa não tem como prever.

Ver resposta

✓ Resposta: (a) Arena.ofConfined() num try-with-resources: a memória é liberada assim que a chamada termina, e o acesso fica restrito à thread que a criou, o que é o mais eficiente e o mais seguro. (b) Arena.global(): a biblioteca precisa existir até o fim do programa, e nunca liberar é exatamente o comportamento desejado. (c) Arena.ofShared(): várias threads precisam acessar o mesmo segmento, o que a confinada não permite, e o fechamento explícito no desligamento libera a memória de forma determinística. (d) Arena.ofAuto(): a memória é liberada pelo coletor de lixo quando nenhum segmento da arena for mais alcançável, parecido com o que acontece com objetos comuns. A desvantagem é que o momento da liberação é imprevisível, e grandes quantidades de memória nativa podem demorar a ser devolvidas, porque o coletor não sabe o quanto de memória nativa cada objeto segura.

Exercício 3

A função C contar_vogais contou 8 vogais em "Programação em Java e C", que tem 9. Explique por quê e proponha duas soluções, uma do lado do Java e outra do lado do C.

Ver resposta

✓ Resposta: O texto chega ao C em UTF-8, e o "ã" é representado por dois bytes (0xC3 0xA3), nenhum dos quais é igual a 'a'. A função, escrita para ASCII, compara byte a byte e não o reconhece. Do lado do Java, uma solução é normalizar o texto antes de enviá-lo, removendo os acentos com o Normalizer e a expressão \p{M} da biblioteca Textos de "Do Arquivo Solto ao Projeto": "Programação" vira "Programacao", e o C conta corretamente. Outra, mais simples, é perguntar se a tarefa precisa mesmo do C: contar vogais em Java, com Character e um Collator, é trivial e correto para qualquer idioma. Do lado do C, a função poderia receber texto em uma codificação larga, como wchar_t, ou usar uma biblioteca de Unicode, como a ICU, para decodificar o UTF-8 e classificar os caracteres. A lição geral: toda fronteira entre linguagens tem premissas sobre a representação dos dados, e elas precisam ser explícitas nos métodos que embrulham as chamadas nativas.

Exercício 4

Compare o que acontece nestas duas situações: um programa C que lê um array depois de liberá-lo com free, e um programa Java que lê um MemorySegment depois de a arena ter sido fechada. Por que a diferença importa?

Ver resposta

✓ Resposta: Em C, ler memória depois do free é undefined behavior: o programa pode ler o valor antigo, que ainda está lá, e parecer funcionar, ou ler dados de outra alocação que reaproveitou o espaço, ou ainda falhar com uma falha de segmentação. Tudo isso sem nenhum aviso, e às vezes só em produção. É uma das causas mais comuns de vulnerabilidades de segurança em software escrito em C. Em Java, com a FFM, o segmento sabe a que arena pertence, e a arena sabe que foi fechada: a leitura lança IllegalStateException: Already closed, na linha exata do erro, com o stack trace. A diferença importa porque transforma um defeito silencioso e potencialmente explorável num erro imediato e diagnosticável. O mesmo vale para os acessos fora dos limites, que em C corrompem memória vizinha e com a FFM lançam IndexOutOfBoundsException. É a proteção que a JVM sempre deu aos arrays, estendida à memória nativa.

Exercício 5

Escreva o código Java que chama a função abs da biblioteca padrão do C, cuja assinatura é int abs(int n), e use-a para calcular abs(-42).

Ver resposta

✓ Resposta:

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

import static java.lang.foreign.ValueLayout.JAVA_INT;

public class Abs {
    private static final MethodHandle ABS;

    static {
        Linker linker = Linker.nativeLinker();
        ABS = linker.downcallHandle(
            linker.defaultLookup().find("abs").orElseThrow(),
            FunctionDescriptor.of(JAVA_INT, JAVA_INT));
    }

    static int abs(int n) {
        try {
            return (int) ABS.invokeExact(n);
        } catch (Throwable t) {
            throw new IllegalStateException(t);
        }
    }

    public static void main(String[] args) {
        IO.println(abs(-42));   // 42
    }
}

Execute com java --enable-native-access=ALL-UNNAMED Abs.java. Como a função só recebe e devolve inteiros, não há memória a alocar e nenhuma arena é necessária. int do C corresponde a JAVA_INT na maioria das plataformas. O MethodHandle é criado uma vez, num bloco estático, e o método Java o embrulha. É claro que, em código real, Math.abs resolve isso sem nenhuma chamada nativa. O exercício serve para fixar a estrutura mínima de uma chamada: localizar o símbolo, descrever a assinatura, criar o handle e invocá-lo com os tipos exatos.

Comentários

Mais em Java

Construção Automatizada — Maven e Gradle
Construção Automatizada — Maven e Gradle

Como Maven e Gradle baixam dependências, compilam, testam e empacotam um…

Fronteiras Explícitas — o Sistema de Módulos
Fronteiras Explícitas — o Sistema de Módulos

Como o module-info.java declara o que cada parte do programa exporta e de que…

Moldes e Instâncias — Classes, Objetos e Construtores
Moldes e Instâncias — Classes, Objetos e Construtores

Classes e objetos em Java: campos, new e referências, o heap, null e a…