kagamihogeの日記

kagamihogeの日記です。

JEP 370: Foreign-Memory Access API (Incubator)をテキトーに訳した

https://openjdk.java.net/jeps/370

JEP 370: Foreign-Memory Access API (Incubator)

Owner    Maurizio Cimadamore
Type    Feature
Scope   JDK
Status  Proposed to Target
Release 14
Component   tools
Discussion  panama dash dev at openjdk dot java dot net
Created 2019/07/09 15:55
Updated 2019/12/05 01:19
Issue   8227446

Summary

javaヒープ外の外部メモリ(foreign memory)に安全かつ効率的なアクセスをするAPIを導入します。

Goals

外部メモリAPIは以下条件を満たす必要があります。

  • 汎用性(Generality): 同一APIで様々な外部メモリ(native memory, persistent memory, managed heap memoryなど)を操作可能であること。
  • 安全性(Safety): どのような種類のメモリであっても、JVMの安全性を損なわないAPIであること。
  • 決定論的(Determinism): メモリ割り当て解放操作はソースコード上から自明であること。

Success Metrics

外部メモリAPIは、今日使われているjava.nio.ByteBuffer, sun.misc.Unsafe、の代替を目指します。新規APIはそうした既存APIと比べてパフォーマンスの向上を目指します。

Motivation

現状、外部メモリにアクセスするライブラリは、Ignite, mapDB, memcached, Netty (ByteBuf)、など多数存在します。

ただ、Java APIは外部メモリアクセスに対しては不十分です。

Java 1.4で導入したByteBuffer APIは、off-heapに割り当てるダイレクトバイトバッファ(direct byte buffers)を生成可能で、Javaから直接off-heapメモリを操作出来ます。しかしダイレクトバッファには制限があります。たとえば、2GB以上のバッファを生成できず、これはByteBuffer APIがintベースのインデクシングスキーマなためです。また、ダイレクトバッファに関連するメモリの割り当て解除はGC任せなため、バッファ使用は面倒事になりがちです。これは、GCから到達不能と見なされたダイレクトバッファのみ解放可能なためです。長年、問題解決や制限解除のための機能拡張リクエストが多数提出されました(例:4496703, 6558368, 4837564, 5029431)。ByteBuffer APIは、off-heapメモリアクセスだけでなく、文字エンコーディング/デコーディングとパーシャルI/O操作に重要なバルクデータのプロデューサー/コンシューマ、のためにも設計されたもので、各種制限はここに由来しています。

もう一つのJavaから外部メモリアクセスする主要な方法はsun.misc.Unsafe APiです。Unsafeは多数のメモリアクセス操作(例:Unsafe::getIntputInt)を公開し、このAPIは良く出来た汎用アドレスモデルによりon-heap同様にoff-heapでも動作します。メモリアクセスにUnsafeを使用するのは極めて有効です。すべてのメモリアクセス操作はJVM intrinsicsとして定義してあるため、メモリアクセス操作は基本的にJITが最適化します。しかし、Unsafe APIはその定義上unsafeで、任意のメモリにアクセスできます(例:Unsafe::getIntlongアドレスを取る)。これにより、例えば解放済みメモリにアクセスするなどで、JavaプログラムでJVMをクラッシュ可能です。加えて、Unsafe APIはサポートされるJava APIでは無いので、その使用は強く非推奨です。

メモリアクセスにJNIを用いることも可能ですが、固有のコストがあるのであまり使う機会はありません。開発パイプライン全体が複雑で、これはJNIがCコードのメンテナンスを開発者に強いるためです。またJNIは本質的に低速で、これはJava-ネイティブ変換がアクセスのたびに発生するためです。

要約すると、外部メモリでは開発者はジレンマとして、安全だが制限(プラス基本的に非効率)のある方法を取るか、安全保障が無く非サポートで危険なUnsafe APIを使うべきか、に直面します。

本JEPでは、サポートが有る・安全・効率的な外部メモリアクセスAPI、を導入します。外部メモリアクセスの問題を解決することで、既存APIの制限や危険性から開発者を解放します。また、新規APIJIT最適化を念頭に作り直すため、パフォーマンスも向上します。

Description

外部メモリアクセスAPIは三つの抽象化、MemorySegment, MemoryAddress, MemoryLayout、を提供します。

MemorySegmentは指定の空間的・一時的境界で隣接メモリ空間のモデルを扱います。MemoryAddressはセグメント内のオフセットと見なせるものです。MemoryLayoutはメモリセグメントの中身のプログラム上の表現です。

メモリセグメントは各種ソース、ネイティブメモリバッファ・Java配列・バイトバッファ(ダイレクトかヒープベース)、から生成します。たとえば、ネイティブメモリセグメントは以下のように生成します。

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
   ...
}

これは100 byteのネイティブメモリバッファに関連付けられたメモリセグメントを生成します。メモリセグメントは空間的な境界(spatially bounded)で、上限・下限があります。この境界外へのメモリアクセスにセグメントを使用しようとすると例外をスローします。try-with-resourceと共に使用する場合、セグメントは一時的な境界(temporally bounded)となり、生成・使用・クローズするとアクセス出来なくなります。セグメントのクローズは常に明示的な操作で、セグメントに関連付けられたメモリ解放など、副作用を伴います。クローズ済みメモリセグメントへのアクセスは例外をスローします。メモリアクセスAPIの安全性を保障するのに空間的・一時的メモリの安全性チェックは、JVMクラッシュを防止などで、重要です。

セグメントとメモリ間のデリファレンスmemory-access var handleで行います。この特殊なvar handleは少なくとも1つの必須アドレス、MemoryAddress型、を持ちます。ここでのMemoryAddressデリファレンス対象のアドレスです。これはMemoryHandlesのファクトリメソッドで取得します。例として、ネイティブセグメントのsetにはメモリアクセスのvar handleを以下のように使用します。

VarHandle intHandle = MemoryHandles.varHandle(int.class);

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
   MemoryAddress base = segment.baseAddress();
   for (int i = 0 ; i < 25 ; i++) {
        intHandle.set(base.offset(i * 4), i);
   }
}

メモリアクセスvar handleは、longの、追加パラメータアドレスを使用可能で、多次元インデックスアクセスなど、複雑なアドレッシングスキーマのために使います。こうしたメモリアクセスvar handleは通常MemoryHandlesコンビネータメソッド呼び出しで取得します。たとえば、インデックスメモリアクセスハンドル経由でネイティブセグメントに直接setするには以下のようにします。

VarHandle intHandle = MemoryHandles.varHandle(int.class);
VarHandle intElemHandle = MemoryHandles.withStride(intHandle, 4);

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
   MemoryAddress base = segment.baseAddress();
   for (int i = 0 ; i < 25 ; i++) {
        intElemHandle.set(base, (long)i);
   }
}

This effectively allows rich, multi-dimensional addressing of an otherwise flat memory buffer.

APIの表現力強化と、上記サンプルのような明示的な数値計算の必要を省略するために、MemoryLayoutはメモリセグメントをプログラム的に記述するのに使います。たとえば、上記サンプルで使用するネイティブメモリセグメントのレイアウトは以下のように記述します。

MemoryLayout intArrayLayout
    = MemoryLayout.ofSequence(25,
                              MemoryLayout.ofValueBits(32,
                                                       ByteOrder.nativeOrder()));

上記は指定(32-bit)のシーケンスメモリレイアウトを25回繰り返したものを生成します。メモリレイアウトを作ると、コードから手計算のコードを追い出して、必要なメモリアクセスvar handleを単に生成するだけになります。

MemoryLayout intArrayLayout
    = MemoryLayout.ofSequence(25,
                              MemoryLayout.ofValueBits(32,
                                                       ByteOrder.nativeOrder()));

VarHandle intElemHandle
    = intArrayLayout.varHandle(int.class,
                               PathElement.sequenceElement());

try (MemorySegment segment = MemorySegment.allocateNative(intArrayLayout)) {
   MemoryAddress base = segment.baseAddress();
   for (int i = 0 ; i < intArrayLayout.elementCount() ; i++) {
        intElemHandle.set(base, (long)i);
   }
}

上の例では、レイアウトパス(layout path)の生成経由でメモリアクセスvar handleを生成してレイアウトインスタンスを作成しています。複雑なレイアウト式からネストしたレイアウトを構築するのにレイアウトパスを使います。レイアウトインスタンスはネイティブメモリセグメントのアロケーションを、レイアウトのサイズとアライメントに基づいて行います。前述サンプルのループ定数はシーケンスレイアウトの要素数で置き換えています。

外部メモリアクセスAPIはまずインキュベートモジュールjdk.incubator.foreignとして提供します。

Alternatives

既存APIByteBuffer, Unsafe, 最悪JNIの使用を継続する。

Risks and Assumptions

安全性と有効性を兼ね備える外部メモリアクセスAPIの構築は困難なタスクです。前述セクションのような空間的・一時的チェックはアクセスのたびに実行の必要があり、そうしたチェックでJITが最適化可能なことは重要です e.g., hoisting them outside of hot loops. メモリアクセスAPIが有効かつ既存のByteBufferやUnsafeのような最適化可能なことを保障する機能がJIT実装に必要になると思われます。

Dependencies

本JEPのAPIProject Panamaの目的であるネイティブ相互運用機能の構築を補助するかもしれません。また、本APIJEP 352 (Non-Volatile Mapped Byte Buffers)のnon-volatileメモリアクセスにおいてより汎用で効果的な方法で使えるかもしれません。

spring-bootでspring-mvcの依存性が解決される仕組みを調べる

spring-bootでは、例えば以下のようにparentとdependencyを指定しておけば、spring-webとかspring-mvcとかの依存性をよしなに解決してくれる。以下のように書くとspring-webmvc-5.2.1.RELEASE.jarが解決されるが、このエントリではそれがどのようにして行われるのか? を勉強したメモを残しておく。

 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

spring-boot-starter-webのdependencies

まず、spring-boot-starter-webpom.xmlを見るとspring-webmvcdependencyがあるのが分かる。ただしversionについては記載が無い。よって、parentであるspring-boot-starter-parentの親子階層を見ていく。

 <dependencies>
        <!--  省略 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
        </dependency>
    </dependencies>

spring-boot-parentの階層

次に、parentで指定するspring-boot-starter-parentの親子階層から見ていく。

spring-boot-starter-parentは上記のような階層になっている。また、spring-boot-starter-webspring-boot-starter-testは、spring-boot-parentの子のspring-boot-startersのmodule、という構造になっている。

spring-boot-buildでspring-framework-bomをimport

spring-webmvcのversionは親階層のどこかで指定しているハズだが、ここでには直接書かれてはいない。

まず、以下のようにspring-framework-bomdependencyManagementでimportしている。

 <properties>
        <!-- 省略 -->           
        <spring-framework.version>5.2.2.BUILD-SNAPSHOT</spring-framework.version>
        <!-- 省略 -->
    </properties>
    <dependencyManagement>
            <dependency>
                <groupId>org.springframework</groupId>
                <artifactId>spring-framework-bom</artifactId>
                <version>${spring-framework.version}</version>
                <scope>import</scope>
                <type>pom</type>
            </dependency>

spring-framework-bom-5.2.1.RELEASE*1をimportしている。で、ここで具体的なversionと共にspring-webmvcが書いてある。

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>org.springframework</groupId>
  <artifactId>spring-framework-bom</artifactId>
  <version>5.2.1.RELEASE</version>
  <packaging>pom</packaging>
  <name>Spring Framework (Bill of Materials)</name>
 <!-- 省略 -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-web</artifactId>
      <version>5.2.1.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-webmvc</artifactId>
      <version>5.2.1.RELEASE</version>
    </dependency>

基本的にはこのようなparent・importの関係によってdepedencyが解決されるのが分かる。

bomとdependencyManagementで必要なライブラリだけ使用する

spring-framework-bom-5.2.1.RELEASEには様々なspring関連ライブラリが並んでいるが、spring-boot-starter-webだけだとspring-webspring-mvcしか使われない。これはmavenのbomという仕組みを使用している。

bomとは、ライブラリ提供者が複数ライブラリのversionの意図した組み合わせ、を記述したもの。使用者はそこから必要なものだけを使用するが、これはmavanのdependencyManagementで実現する。これは名前の通りdependencyを管理するもので、dependencyManagementをparenで親子関係にしたりimportしたりしてもdependencyとして解決されず、<dependencies><dependency>...を記述すると依存性として解決される。ただし、versionは省略出来てdependencyManagementのversionが使用される。これによって、使用者は必要なライブラリだけ、かつ、開発者が意図したversionのものを使用できる。

なお、上記のbomやdependencyManagementの解説はあくまで自分の言葉で書いたものなので、詳細な定義が必要な場合は公式リファレンス等を参照してください。

まとめ

というわけで、

  1. spring関連ライブラリのbomをimport
  2. そのversionはparenの親子関係で指定

のようなタテ・ヨコの関係を駆使した構造になっている。

*1:上記xmlでは5.2.2だが何か無いので解説のため5.2.1を使用

spring-amqpでPOJOをバイナリorJSONで送受信する

手順など

pom.xml

コメントアウトの箇所は後述。

 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.0.RELEASE</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>13</maven.compiler.source>
        <maven.compiler.target>13</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
<!--        <dependency> -->
<!--            <groupId>org.springframework.boot</groupId> -->
<!--            <artifactId>spring-boot-starter-json</artifactId> -->
<!--        </dependency> -->

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

application.properties

src/main/resources/application.propertiesにrabbitmqへの接続設定を記述する。

spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=xxxx
spring.rabbitmq.password=xxxx

送受信するPOJO

適当なクラスを作る。特に深い意味は無いけどlombokは使っていないので、コンストラクタ・getter/setterは省略。

import java.io.Serializable;
import java.util.List;

public class Parent implements Serializable {
    private static final long serialVersionUID = 1L;

    String id;
    List<Child> childs;
       
       //コンストラクタ・getter/setterは省略
}
import java.io.Serializable;

public class Child implements Serializable {
    private static final long serialVersionUID = 1L;

    String id;  
       //コンストラクタ・getter/setterは省略
}

send/receive

以下がエントリポイント。また、起動するとCommandLineRunnerで送信を行いrecievedMessageで受信を待ち受ける。以下のクラスを実行すると送受信が行われる。

コメントアウト箇所については後述。

import java.util.List;

import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;


@SpringBootApplication
public class App implements CommandLineRunner {
    public static void main(String[] args) {
        new SpringApplicationBuilder(App.class).web(WebApplicationType.NONE).run(args);
    }

    @Autowired
    AmqpTemplate amqpTemplate;
    
    @Override
    public void run(String... args) throws Exception {
        var message = new Parent("id12345", List.of(new Child("1"), new Child("2"), new Child("3")));
        amqpTemplate.convertAndSend("test-exchange", "", message);
    }
    
// @Bean
// public MessageConverter jsonMessageConverter(){
//     return new Jackson2JsonMessageConverter();
// }
    
    @RabbitListener(queues="test-queue")
    public void recievedMessage(Parent company) {
      System.out.println("company.id = " + company.id);
      company.childs.forEach(c -> System.out.println("child.id   = " + c.id));
    }
}

管理画面でメッセージを確認

RabbitMQの管理画面から見ると、以下のようにメッセージはバイナリで人間の眼では読めない形式になっている。

f:id:kagamihoge:20191114150050j:plain

JSONで送受信

次に、送受信するPOJOJSON形式に変更する。変更するには、上述のソースでコメントアウトの箇所を外す。

pom.xml

         <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-json</artifactId>
        </dependency>

App.java

import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;

    @Bean
    public MessageConverter jsonMessageConverter(){
        return new Jackson2JsonMessageConverter();
    }

上記変更を加えた後、実行してRabbitMQの管理画面から見ると、以下のようにメッセージがJSON形式になるのが確認できる。

f:id:kagamihoge:20191114150655j:plain

はまった点

Caused by: java.lang.ClassNotFoundException: com.fasterxml.jackson.databind.ObjectMapper

JSONで送受信する場合、pom.xmlspring-boot-starter-jsonの依存性を追加しないと、以下のような実行時エラーが発生する。

Caused by: java.lang.ClassNotFoundException: com.fasterxml.jackson.databind.ObjectMapper
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:602) ~[na:na]
    at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178) ~[na:na]

SimpleMessageConverter only supports String, byte[] and Serializable payloads

バイナリ形式で送受信する場合、POJOSerializableをimplementsする必要がある。JSONの場合はSerializableは要らない。

Caused by: java.lang.IllegalArgumentException: SimpleMessageConverter only supports String, byte[] and Serializable payloads, received: kagami.amqp.Parent
    at org.springframework.amqp.support.converter.SimpleMessageConverter.createMessage(SimpleMessageConverter.java:161) ~[spring-amqp-2.2.0.RELEASE.jar:2.2.0.RELEASE]

No serializer found for class kagami.amqp.Parent and no properties discovered to create BeanSerializer

POJOにgetter/setterが無い、あるいは、publicプロパティが無い場合に以下のような実行時エラーが発生する。

Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class kagami.amqp.Parent and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)
    at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77) ~[jackson-databind-2.10.0.jar:2.10.0]
    at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1191) ~[jackson-databind-2.10.0.jar:2.10.0]