Grabbing the HWND/XID in Java 21 and Later

Grabbing the HWND/XID in Java 21 and Later
Photo by R Mo / Unsplash

A window handle is an operating system identifier referencing a specific on-screen window. Passing this handle to low-level OS APIs allows applications to manipulate window attributes—such as size, position, and visibility—or bind native drawing surfaces.

On Windows, this handle is an HWND (Handle to a Window); on X11-based Linux/Unix systems, it is an XID (X Window ID) (or an NSView pointer on macOS).

In Java applications involving native video playback, you must often retrieve the native window handle of a host component (such as an AWT Canvas or top-level Swing window). Because most media engines (like LibVLC or FFmpeg) are written in C/C++, they require direct access to an underlying OS surface to render video frames directly with hardware acceleration.

In modern versions of Java (Java 21 and later), it has become increasingly more challenging to get a window handle using pure Java. This is because the internal native peer fields—which historically held direct references to underlying OS handles—have been heavily encapsulated to discourage engineers from touching them. Module encapsulation changes introduced by Project Jigsaw in Java 9, combined with the strong encapsulation of JDK internals enforced in JEP 403, make it even harder to bypass this without resorting to clunky runtime flags like --add-opens.

Old methods of getting the window handle, such as using the internal sun.awt package, are simply no longer viable. For instance, the top solution on this Stack Overflow post relies on this snippet, which doesn't even compile in JDK 21+.

import sun.awt.windows.WComponentPeer;

public static long getHWnd(Frame f) {
    return f.getPeer() != null ? ((WComponentPeer) f.getPeer()).getHWnd() : 0;
}

Beyond breaking under modern module boundaries, that implementation is locked strictly to Windows JDK builds and completely fails on Linux (X11/Wayland) or macOS.

While alternative solutions like Java Native Access (JNA) can retrieve the HWND or XID across platforms, it is nowhere near as simple as the four-line solution above—and it forces you to pull in an external third-party dependency for what should conceptually be a trivial task.

Bypassing Encapsulation with Unsafe

If we want to avoid external dependencies and bypass module boundary restrictions without passing --add-opens flags, we have to get our hands dirty.

The window handle is still stored right inside the internal ComponentPeer hierarchy -- it's just sealed behind accessibility checks that regular reflection can't touch. To skip these access control checks, we can use sun.misc.Unsafe, and manually read the underlying handle directly from memory.

import sun.misc.Unsafe;

import javax.swing.*;
import java.awt.*;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class WindowHandle {

    private static final Unsafe UNSAFE;

    static {
        try {
            final Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            UNSAFE = (Unsafe) field.get(null);
        } catch (final IllegalAccessException | NoSuchFieldException e) {
            throw new AssertionError(e);
        }
    }

    public static void main(final String[] args) throws NoSuchFieldException {
        final JFrame frame = new JFrame();
        frame.setSize(400, 400);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        final Canvas canvas = new Canvas();
        frame.add(canvas);
        
        // The component must be realized/visible on screen to generate a native peer
        frame.setVisible(true);

        System.out.println("JFrame HWND/XID: " + getWindowHandle(frame));
        System.out.println("Canvas HWND/XID: " + getWindowHandle(canvas));
    }

    public static long getWindowHandle(final Component component) throws NoSuchFieldException {
        // Component.peer holds the native peer implementation
        final Class<Component> clazz = Component.class;
        final Field field = clazz.getDeclaredField("peer");
        final long offset = UNSAFE.objectFieldOffset(field);
        final Object peer = UNSAFE.getObject(component, offset);
        
        return peer != null ? getWindowHandle0(peer) : 0L;
    }

    private static long getWindowHandle0(final Object peer) {
        final String search = isWindows() ? "hwnd" : "window";
        final Class<?> clazz = peer.getClass();
        final List<Field> fields = getAllFields(clazz);
        for (final Field field : fields) {
            if (field.getName().equals(search)) {
                final long offset = UNSAFE.objectFieldOffset(field);
                return UNSAFE.getLong(peer, offset);
            }
        }
        return 0L;
    }

    private static List<Field> getAllFields(final Class<?> type) {
        final List<Field> fields = new ArrayList<>();
        for (Class<?> c = type; c != null; c = c.getSuperclass()) {
            fields.addAll(Arrays.asList(c.getDeclaredFields()));
        }
        return fields;
    }

    private static boolean isWindows() {
        return System.getProperty("os.name").toLowerCase().contains("win");
    }
}

How It Works Under the Hood

1. Grabbing the Unsafe Instance

private static final Unsafe UNSAFE;

static {
    try {
        final Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        UNSAFE = (Unsafe) field.get(null);
    } catch (final IllegalAccessException | NoSuchFieldException e) {
        throw new AssertionError(e);
    }
}

The constructor for Unsafe is private, and calling Unsafe.getUnsafe() directly throws a SecurityException if your code isn't on the system bootclasspath (which chances are it isn't).

Fortunately, Unsafe holds an internal singleton instance called "theUnsafe". Because sun.misc is still accessible for compatibility (as many libraries still depend on it), we can reflectively grab the instance on initialization.

2. Grabbing the Encapsulated Component.peer

final Class<Component> clazz = Component.class;
final Field field = clazz.getDeclaredField("peer");
final long offset = UNSAFE.objectFieldOffset(field);
final Object peer = UNSAFE.getObject(component, offset);

In modern JDKs, calling field.get(component) fails with an InaccessibleObjectException because java.desktop does not open its internal packages to unnamed modules.

Instead of reading the field via reflection, we use reflection only to get the field's metadata, and pass that to UNSAFE.objectFieldOffset(field). This gives us the exact physical memory offset of the field within the instance, so then we can call UNSAFE.getObject(...) to read that value directly from memory, skipping module access checks entirely.

3. Crawling the Peer Hierarchy

public static List<Field> getAllFields(final Class<?> type) {
    final List<Field> fields = new ArrayList<>();
    for (Class<?> c = type; c != null; c = c.getSuperclass()) {
        fields.addAll(Arrays.asList(c.getDeclaredFields()));
    }
    return fields;
}

The actual OS handle isn't always declared on the concrete peer class itself—it often lives a few levels up the inheritance tree (like inside WComponentPeer or XBaseWindow). Class.getDeclaredFields() only retrieves fields on the exact class, so this method walks up the class hierarchy to gather every declared field across all superclasses.

4. Matching the OS Identifier and Reading the Handle

private static long getWindowHandle0(final Object peer) {
    final String search = isWindows() ? "hwnd" : "window";
    final Class<?> clazz = peer.getClass();
    final List<Field> fields = getAllFields(clazz);

    for (final Field field : fields) {
        if (field.getName().equals(search)) {
            final long offset = UNSAFE.objectFieldOffset(field);
            return UNSAFE.getLong(peer, offset);
        }
    }
    return 0L;
}

Depending on the platform you are running on, the JDK names this internal ID differently:

  • Windows (WComponentPeer): The field is named hwnd.
  • Linux / X11 (XWindow / XBaseWindow): The field is named window (which stores the 32/64-bit XID).

Once we match the target field name, we compute its raw memory offset with UNSAFE.objectFieldOffset(field) and then read it out as a primitive long using UNSAFE.getLong(peer, offset).

You get the raw native window handle on both Windows and Linux, completely self-contained in a single utility class, without any extra dependencies or JVM start-up arguments.