jvmMain and androidMain target a JVM or a JVM-compatible runtime like Android's ART, and Java interoperability is what that buys you. Where reaching an iOS API from Kotlin needs cinterop to generate bindings, reaching a Java API needs nothing at all — Kotlin compiles to the same bytecode Java does, so the two call each other directly, with no interop layer generating anything in between. That also means this only applies to the JVM-backed targets; none of what follows is available from iosMain, jsMain, or wasmJsMain.
Calling Java from Kotlin
This direction is the simple one. A Java class is usable from Kotlin exactly as it's declared, no wrapping required:
// JavaGreeter.java
public class JavaGreeter {
public String greet(String name) {
return "Hello, " + name + "!";
}
}// jvmMain
fun main() {
val greeter = JavaGreeter()
println(greeter.greet("Kotlin developer")) // Hello, Kotlin developer!
}The only real friction comes from what Java doesn't tell Kotlin: Java's type system doesn't distinguish nullable from non-nullable types, so anything coming from a Java API — including this greet() return value — arrives in Kotlin as a platform type, covered below.
Calling Kotlin from Java
This direction needs more care, because some Kotlin features have no direct Java equivalent and get compiled down to something Java can call — controlled by a handful of JVM-specific annotations:
class Greeter {
companion object {
@JvmStatic
val defaultGreeting = "Hi"
@JvmStatic
fun sayHello() = println("Hello, Java!")
}
@JvmField
val label = "Accessible as a field in Java"
@JvmOverloads
fun greet(greeting: String = "Hi") = println(greeting)
@JvmName("printLoudly")
fun printSomething() = println("Something")
}Greeter.sayHello(); // @JvmStatic on a function -> a real static method
String g = Greeter.getDefaultGreeting(); // @JvmStatic on a val -> a static getter, not a bare field
Greeter greeter = new Greeter();
System.out.println(greeter.label); // @JvmField -> a real public field, no getter
greeter.greet(); // @JvmOverloads -> generates greet() and greet(String)
greeter.greet("Hello");
greeter.printLoudly(); // @JvmName -> renames the generated methodWorth being precise about @JvmStatic on a property: it generates a static getter (getDefaultGreeting()), not a public static field — mixing those up is one of the more common Java-interop mistakes. If you actually want a bare field, @JvmField is the annotation for that, and it skips getter/setter generation entirely, at the cost of the member no longer behaving like a normal Kotlin property.
Kotlin functions defined outside a class — top-level functions and extension functions — don't have a natural Java home, so the compiler puts them as static methods on a generated class named after the file: a function in StringUtils.kt shows up in Java as StringUtilsKt.someFunction(...). @file:JvmName("StringUtils") at the top of the file controls that generated name directly, the same way @JvmName renames an individual method.
Platform types and nullability
Kotlin enforces nullability at compile time, but that guarantee stops at the Java boundary. A Java-typed value entering Kotlin — like the return value of greet() above — is a platform type: Kotlin doesn't know whether it can be null, so it neither requires a null check nor protects you from skipping one. Treat any platform type as nullable until you've confirmed otherwise from the Java source or its documentation.
val greeter: JavaGreeter = JavaGreeter()
val result = greeter.greet("test") // platform type: could behave as String or String?Data classes and object singletons cross into Java with a few adjustments worth knowing rather than guessing at. A data class generates ordinary getters (getName(), getAge()) alongside the positional componentN() functions Kotlin itself uses for destructuring — Java code should reach for the named getters, not component1(). A Kotlin object compiles to a class with a single static INSTANCE field, which is how Java code accesses it: SingletonExample.INSTANCE.
Conclusion
Java interoperability is what jvmMain and androidMain get in exchange for targeting a JVM-compatible runtime, and it's largely seamless calling Java from Kotlin, since both compile to the same bytecode. The other direction needs more attention: Kotlin features without a direct Java equivalent — default arguments, top-level functions, properties without backing fields — get shaped by @JvmStatic, @JvmOverloads, @JvmField, and @JvmName, and it's worth knowing exactly what each one generates rather than assuming. The other recurring source of bugs is platform types — Java's lack of null-tracking means anything crossing that boundary loses Kotlin's compile-time safety net, so it's on you to check.