Highlight
SwiftJava supports three directions of interoperability through JavaKit macros, the SwiftKit library, and the swift-java command-line tool: implementing JNI native methods in Swift, importing Java libraries into Swift, and packaging Swift libraries as Java libraries.
Core Content
Many teams run core business logic on Java. To bring Swift into such a codebase, the only path used to be hand-written JNI. JNI has existed since 1997, predating the iPod, and its API has barely changed. Developers had to declare native methods on the Java side, run javac -h to generate C headers, then manually implement functions with long names like Java_com_example_JNIExample_compute, juggling JNIEnv, jobject, and object lifecycles. Konrad put it plainly in the session: it works, but it is hard to get right, and a wrong signature means a crash.
At WWDC25, Apple introduced SwiftJava as an official interoperability project. It has three parts: JavaKit on the Swift side (a library plus macros to simplify JNI calls), SwiftKit on the Java side (a helper library for Java apps to use Swift objects), and the swift-java command-line tool plus SwiftPM plugin. The overall design goal is to let Swift call into Java and be called back by Java, with both directions usable in the same project.
SwiftJava solves three typical scenarios. First, a native method in a Java app is implemented in Swift, using JavaKit macros to replace C headers. Second, a Swift project imports an existing Java library wholesale, using the swift-java tool to invoke Gradle for dependency resolution. Third, an entire Swift library is packaged as a Java library for distribution, using the Foreign Function and Memory API stabilized in Java 22 to bypass JNI. The three scenarios map to three tool combinations that can be used independently or mixed together.
Detailed Content
Implementing JNI Native Methods in Swift
The first scenario starts from the native method on the Java side. Konrad noted that Swift and Java are actually very similar at the runtime level: class inheritance models, automatic memory management (ARC vs GC), generics, and error throwing (Swift Error vs Java Exception) all map cleanly. So having Swift implement Java native methods is a natural starting point.
The toolchain flow is: use the swift-java command-line tool instead of javac -h. The tool reads the Java class and generates Swift bridging code, including a JNIExampleNativeMethods protocol that replaces the original C header. The developer only writes an extension that conforms to this protocol, adding the @JavaImplementation and @JavaMethod macros (09:05).
import JavaKit
import JavaRuntime
import Crypto
@JavaImplementation("com.example.JNIExample")
extension JNIExample: JNIExampleNativeMethods {
@JavaMethod
func compute(_ a: JavaInteger?, _ b: JavaInteger?) -> [UInt8] {
guard let a else { fatalError("Expected non-null parameter 'a'") }
guard let a else { fatalError("Expected non-null parameter 'b'") }
let digest = SHA256Digest([a.intValue(), b.intValue()]) // convenience init defined elsewhere
return digest.toArray()
}
}
Key points:
@JavaImplementation("com.example.JNIExample"): the macro marks this extension as implementing the native methods of the Java classcom.example.JNIExample; the tool generates JNI entry symbols accordingly.extension JNIExample: JNIExampleNativeMethods: bothJNIExampleand theJNIExampleNativeMethodsprotocol are generated by the swift-java tool; the protocol lists all native methods that need a Swift implementation.@JavaMethod: the macro handles JNI details (JNIEnv extraction, parameter unboxing, return value boxing).JavaInteger?: Java’sIntegeris an object and may be null, so it maps to a Swift optional; useintValue()to extract the raw int.import Crypto: the Swift implementation can import any Swift library directly. Here it uses swift-crypto to compute SHA256, avoiding the hassle of calling native crypto libraries from Java.
Importing Java Libraries from Swift
The second scenario flips the direction: a Swift project wants to use an existing Java library (e.g., Apache Commons CSV). Java libraries often have large transitive dependency trees; maintaining the classpath by hand is not practical. SwiftJava borrows Gradle for dependency resolution, then generates Swift bridging code (12:30).
Dependency coordinates follow the Gradle convention of groupId:artifactId:version triples, placed in swift-java.config. Resolution has two paths: use the SwiftPM build plugin to trigger automatically (but you must disable the SwiftPM security sandbox), or manually run swift-java resolve to write the classpath to a file.
swift-java resolve --module-name JavaApacheCommonsCSV
Key points:
swift-java resolve: triggers the dependency resolution subcommand, running outside the sandbox to avoid SwiftPM restrictions.--module-name: points to the target module that contains swift-java.config.- The resolution result is written to a classpath file, which the subsequent Swift module build reads automatically.
After resolution, the Swift side can import the Java library as if it were a native module (13:05):
import JavaKit
import JavaKitIO
import JavaApacheCommonsCSV
let jvm = try JavaVirtualMachine.shared()
let reader = FileReader("sample.csv") // java.io.StringReader
for record in try JavaClass<CSVFormat>().RFC4180.parse(reader)!.getRecords()! {
for field in record.toList()! { // Field: hello
print("Field: \(field)") // Field: example
} // Field: csv
}
print("Done.")
Key points:
JavaVirtualMachine.shared(): starts a JVM instance inside the Swift process; Java code runs in the same process.FileReader("sample.csv"): directly calls the JDK’s ownjava.io.FileReader, wrapped by JavaKitIO.JavaClass<CSVFormat>().RFC4180: accessing static fields of a Java class requiresJavaClass<T>(). Here it fetchesCSVFormat.RFC4180.for record in ...: the returned Java collection can be traversed directly with Swift for-each, backed by JavaKit adapting the Iterable protocol.- Cross-language object lifecycles are managed automatically by JavaKit: local references are promoted to global references when needed, preventing premature GC reclamation.
Exposing a Swift Library to Java
The third scenario is the most complex: distributing an entire Swift library as a Java library. This time JNI is not used; instead it uses the Foreign Function and Memory API stabilized in Java 22 (16:22).
swift-java --input-swift Sources/SwiftyBusiness \
--java-package com.example.business \
--output-swift .build/.../outputs/SwiftyBusiness \
--output-java .build/.../outputs/Java ...
Key points:
--input-swift: points to the Swift source directory to expose. The tool parses public types, methods, and properties.--java-package: the package into which generated Java classes are placed.--output-swift/--output-java: outputs Swift helper code and Java wrapper classes respectively.- Final artifact: Swift is compiled into a dynamic library, and together with the generated Java classes is packaged as a Java library that can be published directly to a Maven repository.
The generated Java class holds a selfMemorySegment pointer to a Swift instance on the native heap. The Java side calls Swift functions directly through the Foreign Function API, skipping the JNI translation layer.
But a Swift struct is a value type with no stable object identity. It must be allocated on the heap before the Java side can reference it with a pointer. This raises the memory management question: when to free? The session offers two SwiftArena approaches. Auto Arena relies on the GC finalizer; it is simple but puts pressure on the GC and has uncertain release timing. Confined Arena pairs with try-with-resources, freeing immediately when the scope ends (18:55).
try (var arena = SwiftArena.ofConfined()) {
var business = new SwiftyBusiness(..., arena);
}
Key points:
SwiftArena.ofConfined(): creates a confined memory arena bound to the current thread.try (...): Java 7 try-with-resources syntax;close()is called automatically when the scope ends.new SwiftyBusiness(..., arena): the constructor receives the arena as an extra argument; the Swift instance is allocated in native memory managed by the arena.- Block ends → arena.close() → Java wrapper destroyed → Swift value on the native side destroyed. Timing is deterministic.
- Advantage: no reliance on GC finalizers, avoids GC performance collapse under heavy object churn, and preserves the ordered destruction semantics that Swift programs depend on.
Core Takeaways
1. Replace hand-written JNI with SwiftJava
Why it is worth doing: the real pain of hand-written JNI lies in signature spelling, object lifecycle management, and caching optimizations. Get a signature wrong and you crash. JavaKit macros move these into compile-time checks, significantly lowering crash risk.
How to start: in an existing Java project, pick a performance-sensitive, logic-simple native method (e.g., hashing, compression). Use the swift-java tool to generate bridging code, write an extension that implements the method, and benchmark against the original.
2. Use swift-java resolve + Gradle to bring in Java ecosystem libraries
Why it is worth doing: many domains (CSV parsing, Apache libraries, Hadoop, enterprise middleware) have no Swift equivalent. Importing Java libraries directly through SwiftJava saves the cost of rewriting or finding substitutes.
How to start: begin with a single, well-defined dependency (e.g., Apache Commons CSV). Write the dependency triple in swift-java.config, run swift-java resolve to produce the classpath, then import JavaXxx in Swift code and try the API.
3. Implement core business logic in Swift, then package it as a Java library
Why it is worth doing: a team may have Java projects, Android projects, and server-side Java microservices that all need the same core rules. Write once in Swift, use the swift-java tool to generate Java wrappers, and all Java projects can consume it. This also leaves an interface for migrating more modules to Swift later.
How to start: pick a domain model with clear boundaries (billing calculation, risk rules, protocol encoding). Express it in Swift with structs, run the swift-java command to generate the Java package, and publish it to an internal Maven repository for other teams.
4. Always use Confined Arena + try-with-resources when Java calls Swift
Why it is worth doing: Auto Arena relies on the GC finalizer. Under heavy object churn it drags the GC down, and release timing is unpredictable. Confined Arena gives lifecycle control back to the developer, matching Swift’s deterministic destruction semantics.
How to start: wrap all Swift object creation inside a try (var arena = SwiftArena.ofConfined()) block. The scope end triggers automatic release. During load testing, watch GC logs and latency distributions, comparing against Auto Arena mode.
5. Follow the SwiftJava project itself
Why it is worth doing: the project is early stage. Gradle integration is still being polished, and the command-line experience is still iterating. Early participants can influence API design and easily contribute to documentation and examples.
How to start: clone github.com/swiftlang/swift-java, run the official example, and file issues on the Swift forums or GitHub for any problems you hit.
Related Sessions
- Embracing Swift concurrency — cross-language interoperability still requires the Swift side to respect actor isolation and concurrency safety
- Improve memory usage and performance with Swift — the Swift memory model is tightly linked to SwiftJava’s native heap allocation
- What’s new in Swift — SwiftJava is part of Swift’s cross-platform strategy
- Optimize SwiftUI performance with Instruments — if a Swift library is called from Java and participates in a UI chain, Instruments remains the primary entry point for performance analysis
Comments
GitHub Issues · utterances