diff --git a/.github/workflows/java8-artifact.yml b/.github/workflows/java8-artifact.yml
new file mode 100644
index 0000000..1f14877
--- /dev/null
+++ b/.github/workflows/java8-artifact.yml
@@ -0,0 +1,107 @@
+name: Java 8 artefact compatibility
+on:
+ push:
+ branches: [ea, develop]
+ pull_request:
+ types: [opened, synchronize, reopened]
+jobs:
+ package:
+ name: Package on Java 8 and repeat without clean
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v6
+ with:
+ distribution: zulu
+ java-version: '8'
+ cache: maven
+ - name: Verify and retain the Java 8 binary
+ run: |
+ ./mvnw -B -ntp verify -l java8-verify.log
+ mkdir -p target/java8-artifact
+ python3 - <<'PY'
+ import shutil
+ import xml.etree.ElementTree as ET
+
+ pom = ET.parse('pom.xml').getroot()
+ ns = {'m': 'http://maven.apache.org/POM/4.0.0'}
+ name = pom.findtext('m:artifactId', namespaces=ns)
+ version = pom.findtext('m:version', namespaces=ns)
+ shutil.copyfile('target/' + name + '-' + version + '.jar',
+ 'target/java8-artifact/library.jar')
+ PY
+ sha256sum target/java8-artifact/library.jar > target/java8-artifact/SHA256SUMS
+ git rev-parse HEAD > target/java8-artifact/revision.txt
+ java -version 2> target/java8-artifact/build-jdk.txt
+ - name: Repeat package without cleaning generated modules
+ run: ./mvnw -B -ntp -DskipTests package -l repeat-package.log
+ - uses: actions/upload-artifact@v7
+ with:
+ name: java8-library
+ path: target/java8-artifact
+ if-no-files-found: error
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: java8-build-evidence
+ path: |
+ java8-verify.log
+ repeat-package.log
+ target/surefire-reports
+ target/failsafe-reports
+ target/invoker-reports
+ target/it/**/build.log
+ target/it/**/target/surefire-reports
+ consumers:
+ name: Java 8 binary on JDK ${{ matrix.java }}
+ needs: package
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ java: ['8', '9', '10', '11', '17', '21', '25']
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v6
+ with:
+ distribution: zulu
+ java-version: ${{ matrix.java }}
+ cache: maven
+ - uses: actions/download-artifact@v8
+ with:
+ name: java8-library
+ path: target/java8-artifact
+ - name: Install the retained binary without rebuilding the library
+ run: |
+ sha256sum --check target/java8-artifact/SHA256SUMS
+ test "$(cat target/java8-artifact/revision.txt)" = "$(git rev-parse HEAD)"
+ ./mvnw -B -ntp org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \
+ -Dfile=target/java8-artifact/library.jar -DpomFile=pom.xml \
+ -DlocalRepositoryPath=target/local-repo -DgeneratePom=false
+ - name: Run class-path and module-path consumers
+ run: |
+ ./mvnw -B -ntp invoker:run invoker:verify -Dinvoker.failIfNoProjects=true -l consumers.log
+ installed=(target/local-repo/net/openhft/zero-allocation-hashing/*/zero-allocation-hashing-*.jar)
+ test "${#installed[@]}" = 1
+ cmp target/java8-artifact/library.jar "${installed[0]}"
+ - name: Check annotation and JDK dependencies
+ if: matrix.java == '21'
+ run: |
+ ./mvnw -B -ntp dependency:copy -Dartifact=com.google.code.findbugs:jsr305:3.0.2 \
+ -DoutputDirectory=target/jdeps
+ jdeps --multi-release 9 -verbose:class --add-modules jsr305 \
+ --module-path target/jdeps/jsr305-3.0.2.jar \
+ target/java8-artifact/library.jar | tee jdeps.log
+ ! grep -F 'not found' jdeps.log
+ grep -E 'net.openhft.hashing.* -> javax.annotation.ParametersAreNonnullByDefault +jsr305$' jdeps.log
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: consumers-jdk-${{ matrix.java }}
+ path: |
+ consumers.log
+ jdeps.log
+ target/java8-artifact/SHA256SUMS
+ target/invoker-reports
+ target/it/**/build.log
+ target/it/**/target/surefire-reports
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
index 642d572..d853aaf 100644
--- a/.mvn/wrapper/maven-wrapper.properties
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -1,2 +1,2 @@
-distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
diff --git a/pom.xml b/pom.xml
index 32be904..d80ccb0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -39,6 +39,10 @@
all,-missing
1.37
+
+
+
+ 1.2.2.Final
openhft
https://sonarcloud.io
@@ -292,6 +296,33 @@
+
+ org.moditect
+ moditect-maven-plugin
+ ${moditect.version}
+
+
+ add-module-info
+ package
+
+ add-module-info
+
+
+
+
+ 9
+
+
+
+ true
+
+ ${project.basedir}/src/main/java9/module-info.java
+
+
+
+
+
+
maven-failsafe-plugin
3.2.5
@@ -309,6 +340,33 @@
+
+
+ org.apache.maven.plugins
+ maven-invoker-plugin
+ 3.9.1
+
+ ${project.build.directory}/it
+ ${project.build.directory}/local-repo
+ verify
+ false
+ true
+
+ clean
+ test
+
+
+
+
+ integration-test
+
+ install
+ run
+ verify
+
+
+
+
@@ -344,13 +402,21 @@
jdk9+-profile
- [9,21)
+ [9,17)
${project.target.release}
+
+ maven-javadoc-plugin
+
+
+ -html5
+
+
+
maven-surefire-plugin
diff --git a/src/it/README.adoc b/src/it/README.adoc
new file mode 100644
index 0000000..331f520
--- /dev/null
+++ b/src/it/README.adoc
@@ -0,0 +1,53 @@
+= Integration Tests
+
+The Maven Invoker fixtures use JUnit 4:
+
+* `simple-usage` targets Java 8 and checks public hash functions across arrays,
+ buffers and character sequences.
+* `module-test` targets Java 9 and checks the explicit descriptor, public API,
+ runtime paths and encapsulation. Invoker skips it on Java 8.
+
+== Build and repeat packaging
+
+[source,bash]
+----
+./mvnw -B -ntp verify -l java8-verify.log
+./mvnw -B -ntp -DskipTests package -l repeat-package.log
+----
+
+The wrapper pins Maven 3.9.11; ModiTect 1.2.2.Final supports Java 8 and requires
+Maven 3.9.6 or later. Ordinary classes target Java 8; the multi-release JAR places
+its Java 9 descriptor in `META-INF/versions/9/module-info.class`.
+The second command retains generated output to exercise `overwriteExistingFiles`.
+`AutomaticModuleNameIT` checks the finished JAR, not the repeated lifecycle.
+With Java 8 selected, `python3 src/it/repeat-package.py ../repeat-package-proof`
+runs this sequence in fresh enabled/disabled-overwrite copies, retaining logs and
+requiring the disabled second package to fail specifically on existing output.
+
+`verify` installs the library in `target/local-repo` before running the fixtures.
+A fresh checkout cannot start with `invoker:run` alone. Invoker inherits Maven
+settings (including mirrors, proxies and credentials), with its own local repository.
+Reports remain under `target`; tests use assertions rather than console output.
+
+== Retained Java 8 binary
+
+The `Java 8 artefact compatibility` workflow retains the JAR, SHA-256, source
+revision and build-JDK identity from `verify`, then repeats `package` without
+cleaning. Separate jobs install that retained binary with `install:install-file`
+and run `invoker:run invoker:verify`, without rebuilding it. They compare installed
+and retained JAR bytes and retain logs/reports on JDK 8/9/10/11/17/21/25.
+The JDK 21 job also resolves JSR-305 with `jdeps` and rejects unresolved annotations.
+These source-compiled fixtures do not establish compatibility for old binaries.
+
+== Runtime contract
+
+The explicit `net.openhft.hashing` module exports only its public hashing package,
+requires `jdk.unsupported` at runtime and declares optional `requires static jsr305`.
+See link:../main/docs/unsafe-and-platform-notes.adoc[Unsafe and platform notes]
+for annotation resolution and the required direct-buffer exports for named and
+class-path consumers; the modular fixture supplies the named-module export.
+
+The library's `LongHashFunctionTest` and `LongTupleHashFunctionTest` put guard bytes
+around direct-buffer payloads and request a range different from the cursor window.
+They assert the hash and unchanged cursor, then restore the payload window before
+the whole-buffer byte-order check. Substituting position/remaining must fail them.
diff --git a/src/it/module-test/invoker.properties b/src/it/module-test/invoker.properties
new file mode 100644
index 0000000..9867019
--- /dev/null
+++ b/src/it/module-test/invoker.properties
@@ -0,0 +1,3 @@
+invoker.goals=clean test
+invoker.buildResult=success
+invoker.java.version=9+
diff --git a/src/it/module-test/pom.xml b/src/it/module-test/pom.xml
new file mode 100644
index 0000000..4fe7479
--- /dev/null
+++ b/src/it/module-test/pom.xml
@@ -0,0 +1,45 @@
+
+
+ 4.0.0
+
+ net.openhft.it
+ module-test
+ 1.0-SNAPSHOT
+
+
+ UTF-8
+ 9
+
+
+
+
+ @project.groupId@
+ @project.artifactId@
+ @project.version@
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+
+
+
+ maven-compiler-plugin
+ 3.14.0
+
+
+ maven-surefire-plugin
+ 3.5.4
+
+ --add-exports java.base/sun.nio.ch=net.openhft.hashing
+
+
+
+
+
diff --git a/src/it/module-test/src/test/java/module-info.java b/src/it/module-test/src/test/java/module-info.java
new file mode 100644
index 0000000..7cc33d5
--- /dev/null
+++ b/src/it/module-test/src/test/java/module-info.java
@@ -0,0 +1,9 @@
+/*
+ * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0
+ */
+module net.openhft.it.module {
+ requires net.openhft.hashing;
+ requires junit;
+ exports net.openhft.it.module;
+ opens net.openhft.it.module to junit;
+}
diff --git a/src/it/module-test/src/test/java/net/openhft/it/module/ModuleTest.java b/src/it/module-test/src/test/java/net/openhft/it/module/ModuleTest.java
new file mode 100644
index 0000000..e8d6c54
--- /dev/null
+++ b/src/it/module-test/src/test/java/net/openhft/it/module/ModuleTest.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0
+ */
+package net.openhft.it.module;
+
+import net.openhft.hashing.LongHashFunction;
+import net.openhft.hashing.LongTupleHashFunction;
+import org.junit.Test;
+
+import java.lang.module.ModuleDescriptor;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+/**
+ * Integration test verifying proper module encapsulation.
+ * This test ensures that only exported packages are accessible.
+ */
+public class ModuleTest {
+
+ private static final String TEST_DATA = "Module Test Data";
+ private static final byte[] TEST_BYTES = TEST_DATA.getBytes(StandardCharsets.UTF_8);
+
+ @Test
+ public void testPublicAPIAccessible() {
+ long xxHash = LongHashFunction.xx().hashBytes(TEST_BYTES);
+ long cityHash = LongHashFunction.city_1_1().hashBytes(TEST_BYTES);
+ long murmurHash = LongHashFunction.murmur_3().hashBytes(TEST_BYTES);
+
+ assertTrue("XxHash should produce non-zero result", xxHash != 0);
+ assertTrue("CityHash should produce non-zero result", cityHash != 0);
+ assertTrue("MurmurHash should produce non-zero result", murmurHash != 0);
+ }
+
+ @Test
+ public void testMainAPIClassAccessible() {
+ try {
+ Class.forName("net.openhft.hashing.LongHashFunction");
+ } catch (ClassNotFoundException e) {
+ fail("Public API net.openhft.hashing.LongHashFunction should be accessible");
+ }
+ }
+
+ @Test
+ public void testExplicitDescriptorContract() {
+ Module module = LongHashFunction.class.getModule();
+ assertTrue("Library should be a named module", module.isNamed());
+ assertEquals("net.openhft.hashing", module.getName());
+
+ ModuleDescriptor descriptor = module.getDescriptor();
+ assertNotNull("Named module should have a descriptor", descriptor);
+ assertFalse("Library should not resolve as an automatic module", descriptor.isAutomatic());
+
+ Set exports = descriptor.exports();
+ assertEquals("Only the public API package should be exported", 1, exports.size());
+ ModuleDescriptor.Exports publicApi = exports.iterator().next();
+ assertEquals("net.openhft.hashing", publicApi.source());
+ assertFalse("Public API export should be unqualified", publicApi.isQualified());
+
+ ModuleDescriptor.Requires unsupported = descriptor.requires().stream()
+ .filter(requirement -> requirement.name().equals("jdk.unsupported"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Descriptor must require jdk.unsupported"));
+ assertFalse("jdk.unsupported must be available at runtime",
+ unsupported.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC));
+
+ ModuleDescriptor.Requires annotations = descriptor.requires().stream()
+ .filter(requirement -> requirement.name().equals("jsr305"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError(
+ "Descriptor must declare jsr305 annotations"));
+ assertTrue("Annotations should remain optional at runtime",
+ annotations.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC));
+ }
+
+ @Test
+ public void testPublicRuntimePathsWithRequiredDirectBufferExport() {
+ LongHashFunction[] functions = {
+ LongHashFunction.city_1_1(),
+ LongHashFunction.farmNa(),
+ LongHashFunction.farmUo(),
+ LongHashFunction.murmur_3(),
+ LongHashFunction.xx(),
+ LongHashFunction.xx3(),
+ LongHashFunction.xx128low(),
+ LongHashFunction.wy_3(),
+ LongHashFunction.metro()
+ };
+
+ for (LongHashFunction function : functions) {
+ long expected = function.hashBytes(TEST_BYTES);
+ assertEquals(expected, function.hashBytes(ByteBuffer.wrap(TEST_BYTES)));
+
+ ByteBuffer direct = ByteBuffer.allocateDirect(TEST_BYTES.length);
+ direct.put(TEST_BYTES).flip();
+ assertEquals(expected, function.hashBytes(direct));
+ assertEquals("Direct buffer position should be unchanged", 0, direct.position());
+ assertEquals("Direct buffer limit should be unchanged", TEST_BYTES.length, direct.limit());
+
+ assertEquals(function.hashChars(TEST_DATA),
+ function.hashChars(new StringBuilder(TEST_DATA)));
+ int[] integers = {1, 2, 3, 4};
+ ByteBuffer integerBytes = ByteBuffer.allocate(integers.length * Integer.BYTES)
+ .order(ByteOrder.nativeOrder());
+ for (int value : integers)
+ integerBytes.putInt(value);
+ // Primitive-array hashing uses the bytes as laid out in native memory.
+ assertEquals(function.hashBytes(integerBytes.array()), function.hashInts(integers));
+ }
+
+ LongTupleHashFunction tuple = LongTupleHashFunction.xx128();
+ ByteBuffer direct = ByteBuffer.allocateDirect(TEST_BYTES.length);
+ direct.put(TEST_BYTES).flip();
+ assertArrayEquals(tuple.hashBytes(TEST_BYTES), tuple.hashBytes(direct));
+ }
+}
diff --git a/src/it/repeat-package.py b/src/it/repeat-package.py
new file mode 100644
index 0000000..0e38d9e
--- /dev/null
+++ b/src/it/repeat-package.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+# Copyright 2013-2026 chronicle.software; SPDX-License-Identifier: Apache-2.0
+"""Run with Java 8 selected: python3 src/it/repeat-package.py NEW_WORK_DIRECTORY."""
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import unittest
+import xml.etree.ElementTree as ET
+import zipfile
+
+SOURCE = Path(__file__).resolve().parents[2]
+
+
+class RepeatPackageTest(unittest.TestCase):
+ def testPackageWithoutClean(self):
+ version = subprocess.check_output(["./mvnw", "-version"], cwd=SOURCE, text=True)
+ (WORK / "toolchain.txt").write_text(version)
+ self.assertIn("Java version: 1.8.", version)
+ for disabled in (False, True):
+ case = WORK / ("overwrite-disabled" if disabled else "overwrite-enabled")
+ shutil.copytree(SOURCE, case, ignore=shutil.ignore_patterns(".git", "target", "*.log"))
+ if disabled:
+ pom = case / "pom.xml"
+ data = pom.read_bytes()
+ setting = b"true"
+ self.assertEqual(data.count(setting), 1)
+ pom.write_bytes(data.replace(setting, setting.replace(b"true", b"false")))
+ records = []
+ for repeat in (False, True):
+ log = "repeat-package.log" if repeat else "java8-verify.log"
+ cmd = ["./mvnw", "-B", "-ntp"] + (["-DskipTests", "package"] if repeat else ["verify"]) + ["-l", log]
+ with (case / (log + ".console")).open("wb") as output:
+ run = subprocess.run(cmd, cwd=case, stdout=output, stderr=subprocess.STDOUT)
+ records.append({"command": cmd, "exit_code": run.returncode})
+ (case / "commands.json").write_text(json.dumps(records, indent=2) + "\n")
+ text = (case / log).read_text()
+ if disabled and repeat:
+ self.assertNotEqual(run.returncode, 0, "Overwrite-disabled repeat unexpectedly passed")
+ self.assertIn("moditect-maven-plugin:1.2.2.Final:add-module-info", text)
+ self.assertRegex(text, r"(?i)(already exists|FileAlreadyExistsException)")
+ else:
+ self.assertEqual(run.returncode, 0, "Build/setup failed; see " + str(case / log))
+ self.assertIn("BUILD SUCCESS", text)
+ if not repeat:
+ report = ET.parse(case / "target/failsafe-reports/TEST-net.openhft.hashing.AutomaticModuleNameIT.xml").getroot()
+ self.assertEqual([int(report.get(k, "0")) for k in ("tests", "failures", "errors", "skipped")], [3, 0, 0, 0])
+ project = ET.parse(case / "pom.xml").getroot()
+ ns = {"m": "http://maven.apache.org/POM/4.0.0"}
+ artifact = project.findtext("m:artifactId", namespaces=ns)
+ artifact_version = project.findtext("m:version", namespaces=ns)
+ jar = case / "target" / f"{artifact}-{artifact_version}.jar"
+ shutil.copyfile(jar, case / "retained-verify.jar")
+ elif not disabled:
+ def classes(path):
+ with zipfile.ZipFile(path) as archive:
+ return {n: archive.read(n) for n in archive.namelist() if n.endswith(".class")}
+ self.assertEqual(classes(case / "retained-verify.jar"), classes(jar))
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ sys.exit(__doc__)
+ WORK = Path(sys.argv.pop(1)).resolve()
+ if WORK == SOURCE or SOURCE in WORK.parents:
+ sys.exit("Work directory must be outside the source tree")
+ WORK.mkdir()
+ unittest.main(verbosity=2)
diff --git a/src/it/simple-usage/invoker.properties b/src/it/simple-usage/invoker.properties
new file mode 100644
index 0000000..ac71e23
--- /dev/null
+++ b/src/it/simple-usage/invoker.properties
@@ -0,0 +1,2 @@
+invoker.goals=clean test
+invoker.buildResult=success
diff --git a/src/it/simple-usage/pom.xml b/src/it/simple-usage/pom.xml
new file mode 100644
index 0000000..7584801
--- /dev/null
+++ b/src/it/simple-usage/pom.xml
@@ -0,0 +1,42 @@
+
+
+ 4.0.0
+
+ net.openhft.it
+ simple-usage
+ 1.0-SNAPSHOT
+
+
+ UTF-8
+ 8
+
+
+
+
+ @project.groupId@
+ @project.artifactId@
+ @project.version@
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+
+
+
+ maven-compiler-plugin
+ 3.14.0
+
+
+ maven-surefire-plugin
+ 3.5.4
+
+
+
+
diff --git a/src/it/simple-usage/src/test/java/net/openhft/it/HashingTest.java b/src/it/simple-usage/src/test/java/net/openhft/it/HashingTest.java
new file mode 100644
index 0000000..d2f1f50
--- /dev/null
+++ b/src/it/simple-usage/src/test/java/net/openhft/it/HashingTest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0
+ */
+package net.openhft.it;
+
+import net.openhft.hashing.LongHashFunction;
+import org.junit.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.*;
+
+/**
+ * Integration test demonstrating usage of Zero-Allocation-Hashing library.
+ */
+public class HashingTest {
+
+ private static final String TEST_STRING = "Hello, World!";
+ private static final byte[] TEST_BYTES = TEST_STRING.getBytes(StandardCharsets.UTF_8);
+
+ @Test
+ public void testXxHash() {
+ LongHashFunction xxHash = LongHashFunction.xx();
+ ByteBuffer buffer = ByteBuffer.wrap(TEST_BYTES);
+
+ long hashFromBytes = xxHash.hashBytes(TEST_BYTES);
+ long hashFromBuffer = xxHash.hashBytes(buffer);
+ buffer.rewind();
+ long hashFromChars = xxHash.hashChars(TEST_STRING);
+
+ assertEquals("XxHash: byte[] and ByteBuffer hashes should match", hashFromBytes, hashFromBuffer);
+ assertTrue("XxHash from bytes should be non-zero", hashFromBytes != 0);
+ assertTrue("XxHash from chars should be non-zero", hashFromChars != 0);
+ }
+
+ @Test
+ public void testCityHash() {
+ LongHashFunction cityHash = LongHashFunction.city_1_1();
+ ByteBuffer buffer = ByteBuffer.wrap(TEST_BYTES);
+
+ long hashFromBytes = cityHash.hashBytes(TEST_BYTES);
+ long hashFromBuffer = cityHash.hashBytes(buffer);
+ buffer.rewind();
+ long hashFromChars = cityHash.hashChars(TEST_STRING);
+
+ assertEquals("CityHash: byte[] and ByteBuffer hashes should match", hashFromBytes, hashFromBuffer);
+ assertTrue("CityHash from bytes should be non-zero", hashFromBytes != 0);
+ assertTrue("CityHash from chars should be non-zero", hashFromChars != 0);
+ }
+
+ @Test
+ public void testMurmurHash() {
+ LongHashFunction murmur = LongHashFunction.murmur_3();
+ ByteBuffer buffer = ByteBuffer.wrap(TEST_BYTES);
+
+ long hashFromBytes = murmur.hashBytes(TEST_BYTES);
+ long hashFromBuffer = murmur.hashBytes(buffer);
+ buffer.rewind();
+ long hashFromChars = murmur.hashChars(TEST_STRING);
+
+ assertEquals("MurmurHash3: byte[] and ByteBuffer hashes should match", hashFromBytes, hashFromBuffer);
+ assertTrue("MurmurHash3 from bytes should be non-zero", hashFromBytes != 0);
+ assertTrue("MurmurHash3 from chars should be non-zero", hashFromChars != 0);
+ }
+
+ @Test
+ public void testFarmHash() {
+ LongHashFunction farmHash = LongHashFunction.farmUo();
+ ByteBuffer buffer = ByteBuffer.wrap(TEST_BYTES);
+
+ long hashFromBytes = farmHash.hashBytes(TEST_BYTES);
+ long hashFromBuffer = farmHash.hashBytes(buffer);
+ buffer.rewind();
+ long hashFromChars = farmHash.hashChars(TEST_STRING);
+
+ assertEquals("FarmHash: byte[] and ByteBuffer hashes should match", hashFromBytes, hashFromBuffer);
+ assertTrue("FarmHash from bytes should be non-zero", hashFromBytes != 0);
+ assertTrue("FarmHash from chars should be non-zero", hashFromChars != 0);
+ }
+
+ @Test
+ public void testPublicAPIAccessible() {
+ try {
+ Class.forName("net.openhft.hashing.LongHashFunction");
+ } catch (ClassNotFoundException e) {
+ fail("Public API net.openhft.hashing.LongHashFunction should be accessible");
+ }
+ }
+}
diff --git a/src/main/docs/architecture-overview.adoc b/src/main/docs/architecture-overview.adoc
index 44dce67..e95af2d 100644
--- a/src/main/docs/architecture-overview.adoc
+++ b/src/main/docs/architecture-overview.adoc
@@ -36,7 +36,8 @@ It currently delivers 128-bit MurmurHash3 and XXH3 outputs and mirrors the singl
* `net.openhft.hashing.Util.VALID_STRING_HASH` selects the correct `StringHash` strategy at JVM initialisation time by inspecting `java.vm.name` and `java.version`, covering HotSpot, OpenJ9, Zing, and unknown VMs (`Util.java:29-63`).
* `ModernHotSpotStringHash`, `ModernCompactStringHash`, and `HotSpotPrior7u6StringHash` encode the memory layout differences between pre-compact, compact-string, and legacy HotSpot builds.
When the VM cannot be recognised, `UnknownJvmStringHash` provides a defensive fallback.
-* Direct buffer hashing uses `sun.nio.ch.DirectBuffer` addresses pulled via `LongHashFunction.hashBytes(ByteBuffer)` and `LongHashFunction.hashMemory(long, long)`; `Util.getDirectBufferAddress` centralises the address extraction (`Util.java:65-68`).
+* Direct buffer hashing calls `sun.nio.ch.DirectBuffer.address()` and passes the native address to the allocation-free `Unsafe` path (`LongHashFunction.hashByteBuffer`, `LongTupleHashFunction.hashByteBuffer`, and `Util.getDirectBufferAddress`).
+See link:unsafe-and-platform-notes.adoc[Unsafe and platform notes] for the direct-call rationale and required JVM exports.
=== Supporting Utilities
diff --git a/src/main/docs/project-requirements.adoc b/src/main/docs/project-requirements.adoc
index d6d0aa7..89b2044 100644
--- a/src/main/docs/project-requirements.adoc
+++ b/src/main/docs/project-requirements.adoc
@@ -55,7 +55,7 @@ The library must implement the following non-cryptographic hash algorithms:
=== 3.3. Compatibility (NF-O)
* **NF-O-001:** **JDK Version:** The library must support JDK 8 through the current LTS (JDK 21+).
-* **NF-O-002:** **Module System:** The library must function in Java 9+ environments. Tests and runtime require `--add-opens java.base/jdk.internal.misc=ALL-UNNAMED --add-opens java.base/sun.nio.ch=ALL-UNNAMED` for `sun.misc.Unsafe` and `sun.nio.ch.DirectBuffer` access (see `unsafe-and-platform-notes.adoc`).
+* **NF-O-002:** **Module System:** The library must function as explicit module `net.openhft.hashing` on Java 9+, with runtime `jdk.unsupported` and application-supplied direct-buffer exports as specified in link:unsafe-and-platform-notes.adoc[Unsafe and platform notes].
* **NF-O-003:** **VM Support:** The library must support major JVMs including HotSpot, OpenJDK, and OpenJ9/IBM J9.
== 4. Interface Constraints
diff --git a/src/main/docs/unsafe-and-platform-notes.adoc b/src/main/docs/unsafe-and-platform-notes.adoc
index 492a6eb..a6ff7a8 100644
--- a/src/main/docs/unsafe-and-platform-notes.adoc
+++ b/src/main/docs/unsafe-and-platform-notes.adoc
@@ -9,20 +9,29 @@ Chronicle Software
* `UnsafeAccess` reflects on `sun.misc.Unsafe.theUnsafe` to obtain the singleton and uses it for raw array access, field offsets, and direct memory loads (`UnsafeAccess.java:40-118`).
This keeps hashing allocation-free but depends on the `jdk.unsupported` module.
-* Direct buffer handling casts to `sun.nio.ch.DirectBuffer` to retrieve native addresses for `hashBytes(ByteBuffer)` and `hashMemory` (`Util.java:65-68`, `LongHashFunction.java:430-470`).
-The code path assumes the buffer is direct; heap buffers are copied through array access instead.
+* Direct buffer handling casts to `sun.nio.ch.DirectBuffer` and calls `address()` directly before hashing the native memory.
+This allocation-free fast path has no reflective fallback when access is unavailable.
* String hashing inspects `java.lang.String.value` and, on compact-string VMs, treats the backing `byte[]` as Latin-1 using `CompactLatin1CharSequenceAccess` (`ModernCompactStringHash.java:10-62`).
Older HotSpot builds and other JVMs fall back to UTF-16 behaviour through `ModernHotSpotStringHash` or `HotSpotPrior7u6StringHash`.
=== JVM Configuration
-* On Java 9 and newer, strong encapsulation may block reflective access to the JDK internals above.
-Add the following opens/exports when running with a strict module layer:
-** `--add-opens java.base/sun.misc=ALL-UNNAMED` (access to `Unsafe.theUnsafe`).
-** `--add-exports java.base/sun.nio.ch=ALL-UNNAMED` (casts to `DirectBuffer`).
-** `--add-opens java.base/java.lang=ALL-UNNAMED` (reading `String.value` for compact strings).
-* The library does not automatically request these permissions.
-Applications embedding Zero-Allocation Hashing must configure the JVM command line or module descriptors accordingly.
+* The explicit module descriptor declares `requires jdk.unsupported` for `sun.misc.Unsafe`.
+* `requires static jsr305` declares the optional nullability-annotation dependency.
+Applications inspecting those annotations must supply and resolve `jsr305`; ordinary hashing does not require it at runtime.
+* On Java 9 and newer, direct-buffer hashing also requires one of the following runtime options:
+** Named-module use: `--add-exports java.base/sun.nio.ch=net.openhft.hashing`
+** Class-path use: `--add-exports java.base/sun.nio.ch=ALL-UNNAMED`
+The launcher must supply the export: a descriptor cannot request another module's exports.
+* Java 24 and 25 emit terminal-deprecation warnings for some `sun.misc.Unsafe` memory-access methods.
+The Java 25 default is `--sun-misc-unsafe-memory-access=warn`, which permits execution and emits at most one warning per JVM.
+The first warning from this library normally identifies `UnsafeAccess.arrayBaseOffset`, but suppressing or replacing that one call would not resolve the underlying dependency: hashing also uses `getByte`, `getShort`, `getInt`, and `getLong` throughout its hot paths.
+* Applications that have explicitly accepted this temporary compatibility risk can suppress the warning on Java 24 and 25 with `--sun-misc-unsafe-memory-access=allow`.
+This is an application-wide launcher policy, not a library setting, and does not mitigate the future-removal risk.
+Use `debug` instead when locating every call; `deny` makes the current implementation fail during `UnsafeAccess` initialisation.
+* Removing the warning properly requires replacing all affected on-heap and off-heap memory operations with supported APIs, principally `VarHandle` and the Foreign Function and Memory API.
+That work requires version-specific implementations and performance validation, and is separate from JPMS packaging.
+See https://openjdk.org/jeps/498[JEP 498] and https://openjdk.org/jeps/471[JEP 471].
=== Platform Behaviour
diff --git a/src/main/java9/module-info.java b/src/main/java9/module-info.java
new file mode 100644
index 0000000..033ad60
--- /dev/null
+++ b/src/main/java9/module-info.java
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2013-2025 chronicle.software; SPDX-License-Identifier: Apache-2.0
+ */
+//! Publish an explicit module with only the public hashing package exported.
+//! ModuleTest#testExplicitDescriptorContract rejects an automatic module or extra exports;
+//! #testPublicAPIAccessible checks consumers can still call the API.
+module net.openhft.hashing {
+ //! Unsafe remains a runtime dependency of existing hashing paths, not an optional annotation.
+ //! ModuleTest#testExplicitDescriptorContract requires a non-static jdk.unsupported edge;
+ //! #testPublicRuntimePathsWithRequiredDirectBufferExport exercises those paths.
+ requires jdk.unsupported;
+ //! Declare compile-time annotations without forcing ordinary runtime consumers to install them.
+ //! ModuleTest#testExplicitDescriptorContract requires the STATIC jsr305 dependency.
+ requires static jsr305;
+ exports net.openhft.hashing;
+}
diff --git a/src/test/java/net/openhft/hashing/AutomaticModuleNameIT.java b/src/test/java/net/openhft/hashing/AutomaticModuleNameIT.java
index 1de4c03..ef30637 100644
--- a/src/test/java/net/openhft/hashing/AutomaticModuleNameIT.java
+++ b/src/test/java/net/openhft/hashing/AutomaticModuleNameIT.java
@@ -5,14 +5,17 @@
import org.junit.Test;
+import java.io.DataInputStream;
import java.io.File;
import java.io.IOException;
import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class AutomaticModuleNameIT {
@@ -32,6 +35,38 @@ public void packagedJarDeclaresAutomaticModuleName() throws IOException {
assertNotNull("Packaged JAR has no manifest: " + packagedJar, manifest);
final Attributes attributes = manifest.getMainAttributes();
assertEquals(EXPECTED_MODULE_NAME, attributes.getValue("Automatic-Module-Name"));
+ assertEquals("Versioned descriptor must be enabled", "true",
+ attributes.getValue("Multi-Release"));
+ }
+ }
+
+ @Test
+ public void java8BuildProducesJava9ModuleDescriptor() throws IOException {
+ final String packagedJarPath = System.getProperty("packaged.jar");
+ assertNotNull("packaged.jar system property", packagedJarPath);
+
+ final File packagedJar = new File(packagedJarPath);
+ try (JarFile jarFile = new JarFile(packagedJar)) {
+ final JarEntry descriptor = jarFile.getJarEntry("META-INF/versions/9/module-info.class");
+ assertNotNull("Packaged JAR has no Java 9 module descriptor", descriptor);
+
+ try (DataInputStream input = new DataInputStream(jarFile.getInputStream(descriptor))) {
+ assertEquals("Invalid class-file magic", 0xCAFEBABE, input.readInt());
+ input.readUnsignedShort();
+ assertEquals("Descriptor should target Java 9 class files", 53, input.readUnsignedShort());
+ }
+ }
+ }
+
+ @Test
+ public void packagedJarDoesNotContainJdkCompilationStub() throws IOException {
+ final String packagedJarPath = System.getProperty("packaged.jar");
+ assertNotNull("packaged.jar system property", packagedJarPath);
+
+ try (JarFile jarFile = new JarFile(new File(packagedJarPath))) {
+ assertNull("The JDK supplies sun.nio.ch.DirectBuffer at runtime; the compile-time stub "
+ + "must not be packaged",
+ jarFile.getJarEntry("sun/nio/ch/DirectBuffer.class"));
}
}
}
diff --git a/src/test/java/net/openhft/hashing/LongHashFunctionTest.java b/src/test/java/net/openhft/hashing/LongHashFunctionTest.java
index fa4380a..dc3277f 100644
--- a/src/test/java/net/openhft/hashing/LongHashFunctionTest.java
+++ b/src/test/java/net/openhft/hashing/LongHashFunctionTest.java
@@ -152,11 +152,34 @@ private static void testByteBuffers(LongHashFunction f, long eh, int len, ByteBu
bb2.put(bb);
assertEquals("byte buffer little endian off len", eh, f.hashBytes(bb2, 1, len));
+ ByteBuffer direct = ByteBuffer.allocateDirect(len + 2).order(LITTLE_ENDIAN);
+ direct.put(0, (byte) 0x6D);
+ direct.put(len + 1, (byte) 0xB7);
+ ((Buffer)direct).position(1);
+ ByteBuffer directSource = bb.duplicate();
+ ((Buffer)directSource).clear();
+ direct.put(directSource);
+ ((Buffer)direct).position(1);
+ ((Buffer)direct).limit(len + 1);
+ assertEquals("direct byte buffer", eh, f.hashBytes(direct));
+ assertEquals("direct byte buffer position unchanged", 1, direct.position());
+ assertEquals("direct byte buffer limit unchanged", len + 1, direct.limit());
+
+ // The explicit range excludes guards that are inside the active window.
+ ((Buffer)direct).clear();
+ assertEquals("direct byte buffer off len", eh, f.hashBytes(direct, 1, len));
+ assertEquals("direct range position unchanged", 0, direct.position());
+ assertEquals("direct range limit unchanged", len + 2, direct.limit());
+ ((Buffer)direct).position(1);
+ ((Buffer)direct).limit(len + 1);
+
((Buffer)bb.order(BIG_ENDIAN)).clear();
assertEquals("byte buffer big endian", eh, f.hashBytes(bb));
bb2.order(BIG_ENDIAN);
assertEquals("byte buffer big endian off len", eh, f.hashBytes(bb2, 1, len));
+ direct.order(BIG_ENDIAN);
+ assertEquals("direct byte buffer big endian", eh, f.hashBytes(direct));
((Buffer)bb.order(nativeOrder())).clear();
}
diff --git a/src/test/java/net/openhft/hashing/LongTupleHashFunctionTest.java b/src/test/java/net/openhft/hashing/LongTupleHashFunctionTest.java
index 9781aa2..78c2b36 100644
--- a/src/test/java/net/openhft/hashing/LongTupleHashFunctionTest.java
+++ b/src/test/java/net/openhft/hashing/LongTupleHashFunctionTest.java
@@ -204,11 +204,34 @@ private static void testByteBuffers(LongTupleHashFunction f, long[] eh, int len,
bb2.put(bb);
assertArrayEquals("byte buffer little endian off len", eh, f.hashBytes(bb2, 1, len));
+ ByteBuffer direct = ByteBuffer.allocateDirect(len + 2).order(LITTLE_ENDIAN);
+ direct.put(0, (byte) 0x6D);
+ direct.put(len + 1, (byte) 0xB7);
+ ((Buffer)direct).position(1);
+ ByteBuffer directSource = bb.duplicate();
+ ((Buffer)directSource).clear();
+ direct.put(directSource);
+ ((Buffer)direct).position(1);
+ ((Buffer)direct).limit(len + 1);
+ assertArrayEquals("direct byte buffer", eh, f.hashBytes(direct));
+ assertEquals("direct byte buffer position unchanged", 1, direct.position());
+ assertEquals("direct byte buffer limit unchanged", len + 1, direct.limit());
+
+ // The explicit range excludes guards that are inside the active window.
+ ((Buffer)direct).clear();
+ assertArrayEquals("direct byte buffer off len", eh, f.hashBytes(direct, 1, len));
+ assertEquals("direct range position unchanged", 0, direct.position());
+ assertEquals("direct range limit unchanged", len + 2, direct.limit());
+ ((Buffer)direct).position(1);
+ ((Buffer)direct).limit(len + 1);
+
((Buffer)bb.order(BIG_ENDIAN)).clear();
assertArrayEquals("byte buffer big endian", eh, f.hashBytes(bb));
bb2.order(BIG_ENDIAN);
assertArrayEquals("byte buffer big endian off len", eh, f.hashBytes(bb2, 1, len));
+ direct.order(BIG_ENDIAN);
+ assertArrayEquals("direct byte buffer big endian", eh, f.hashBytes(direct));
((Buffer)bb.order(nativeOrder())).clear();
}