diff a/test/jdk/java/lang/Thread/virtual/SynchronizedNative.java b/test/jdk/java/lang/Thread/virtual/SynchronizedNative.java
--- /dev/null
+++ b/test/jdk/java/lang/Thread/virtual/SynchronizedNative.java
@@ -0,0 +1,474 @@
+/*
+ * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+/*
+ * @test id=default
+ * @summary Test virtual threads with a synchronized native method and a native method
+ *      that enter/exits a monitor with JNI MonitorEnter/MonitorExit
+ * @requires vm.continuations
+ * @modules java.base/java.lang:+open
+ * @library /test/lib
+ * @run junit/othervm SynchronizedNative
+ */
+
+/*
+ * @test id=Xint
+ * @requires vm.continuations
+ * @modules java.base/java.lang:+open
+ * @library /test/lib
+ * @run junit/othervm -Xint SynchronizedNative
+ */
+
+/*
+ * @test id=Xcomp-TieredStopAtLevel1
+ * @requires vm.continuations
+ * @modules java.base/java.lang:+open
+ * @library /test/lib
+ * @run junit/othervm -Xcomp -XX:TieredStopAtLevel=1 SynchronizedNative
+ */
+
+/*
+ * @test id=Xcomp-noTieredCompilation
+ * @requires vm.continuations
+ * @modules java.base/java.lang:+open
+ * @library /test/lib
+ * @run junit/othervm -Xcomp -XX:-TieredCompilation SynchronizedNative
+ */
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Phaser;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.LockSupport;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import jdk.test.lib.thread.VThreadRunner;
+import jdk.test.lib.thread.VThreadPinner;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import static org.junit.jupiter.api.Assertions.*;
+
+class SynchronizedNative {
+    private static int initialParallelism;
+
+    @BeforeAll
+    static void setup() throws Exception {
+        // need at least two carriers when main thread is a virtual thread
+        if (Thread.currentThread().isVirtual()) {
+            initialParallelism = VThreadRunner.ensureParallelism(2);
+        }
+        System.loadLibrary("SynchronizedNative");
+    }
+
+    @AfterAll
+    static void finish() {
+        // restore parallelism if needed
+        if (initialParallelism > 0) {
+            VThreadRunner.setParallelism(initialParallelism);
+        }
+    }
+
+    /**
+     * Test entering a monitor with a synchronized native method, no contention.
+     */
+    @Test
+    void testEnter() throws Exception {
+        Object lock = this;
+        VThreadRunner.run(() -> {
+            runWithSynchronizedNative(() -> {
+                assertTrue(Thread.holdsLock(lock));
+            });
+            assertFalse(Thread.holdsLock(lock));
+        });
+    }
+
+    /**
+     * Test reentering a monitor with synchronized native method, no contention.
+     */
+    @Test
+    void testReenter() throws Exception {
+        Object lock = this;
+        VThreadRunner.run(() -> {
+
+            // enter, reenter with a synchronized native method
+            synchronized (lock) {
+                runWithSynchronizedNative(() -> {
+                    assertTrue(Thread.holdsLock(lock));
+                });
+                assertTrue(Thread.holdsLock(lock));
+            }
+            assertFalse(Thread.holdsLock(lock));
+
+            // enter with synchronized native method, renter with synchronized statement
+            runWithSynchronizedNative(() -> {
+                assertTrue(Thread.holdsLock(lock));
+                synchronized (lock) {
+                    assertTrue(Thread.holdsLock(lock));
+                }
+                assertTrue(Thread.holdsLock(lock));
+            });
+            assertFalse(Thread.holdsLock(lock));
+
+            // enter with synchronized native method, reenter with synchronized native method
+            runWithSynchronizedNative(() -> {
+                assertTrue(Thread.holdsLock(lock));
+                runWithSynchronizedNative(() -> {
+                    assertTrue(Thread.holdsLock(lock));
+                });
+                assertTrue(Thread.holdsLock(lock));
+            });
+            assertFalse(Thread.holdsLock(lock));
+        });
+    }
+
+    /**
+     * Test entering a monitor with a synchronized native method and with contention.
+     */
+    @Test
+    void testEnterWithContention() throws Exception {
+        var lock = this;
+        var started = new CountDownLatch(1);
+        var entered = new AtomicBoolean();
+        var vthread = Thread.ofVirtual().unstarted(() -> {
+            started.countDown();
+            runWithSynchronizedNative(() -> {
+                assertTrue(Thread.holdsLock(lock));
+                entered.set(true);
+            });
+        });
+        try {
+            synchronized (lock) {
+                vthread.start();
+
+                // wait for thread to start and block
+                started.await();
+                await(vthread, Thread.State.BLOCKED);
+
+                assertFalse(entered.get());
+            }
+        } finally {
+            vthread.join();
+        }
+        assertTrue(entered.get());
+    }
+
+    /**
+     * Returns a stream of elements that are ordered pairs of platform and virtual thread
+     * counts. 0,2,4 platform threads. 2,4,6,8 virtual threads.
+     */
+    static Stream<Arguments> threadCounts() {
+        return IntStream.range(0, 5)
+                .filter(i -> i % 2 == 0)
+                .mapToObj(i -> i)
+                .flatMap(np -> IntStream.range(2, 9)
+                        .filter(i -> i % 2 == 0)
+                        .mapToObj(vp -> Arguments.of(np, vp)));
+    }
+
+    /**
+     * Execute a task concurrently from both platform and virtual threads.
+     */
+    private void executeConcurrently(int nPlatformThreads,
+                                     int nVirtualThreads,
+                                     Runnable task) throws Exception {
+        int parallism = nVirtualThreads;
+        if (Thread.currentThread().isVirtual()) {
+            parallism++;
+        }
+        int previousParallelism = VThreadRunner.ensureParallelism(parallism);
+        try {
+            int nthreads = nPlatformThreads + nVirtualThreads;
+            var phaser = new Phaser(nthreads + 1);
+
+            // start all threads
+            var threads = new Thread[nthreads];
+            int index = 0;
+            for (int i = 0; i < nPlatformThreads; i++) {
+                threads[index++] = Thread.ofPlatform().start(() -> {
+                    phaser.arriveAndAwaitAdvance();
+                    task.run();
+                });
+            }
+            for (int i = 0; i < nVirtualThreads; i++) {
+                threads[index++] = Thread.ofVirtual().start(() -> {
+                    phaser.arriveAndAwaitAdvance();
+                    task.run();
+                });
+            }
+
+            // wait for all threads to start
+            phaser.arriveAndAwaitAdvance();
+            System.err.printf("  %d threads started%n", nthreads);
+
+            // wait for all threads to terminate
+            for (Thread thread : threads) {
+                if (thread != null) {
+                    System.err.printf("  join %s ...%n", thread);
+                    thread.join();
+                }
+            }
+        } finally {
+            // reset parallelism
+            VThreadRunner.setParallelism(previousParallelism);
+        }
+    }
+
+
+    /**
+     * Test entering a monitor with a synchronized native method from many threads
+     * at the same time.
+     */
+    @ParameterizedTest
+    @MethodSource("threadCounts")
+    void testEnterConcurrently(int nPlatformThreads, int nVirtualThreads) throws Exception {
+        var counter = new Object() {
+            int value;
+            int value() { return value; }
+            void increment() { value++; }
+        };
+        var lock = this;
+        executeConcurrently(nPlatformThreads, nVirtualThreads, () -> {
+            runWithSynchronizedNative(() -> {
+                assertTrue(Thread.holdsLock(lock));
+                counter.increment();
+                LockSupport.parkNanos(100_000_000);  // 100ms
+            });
+        });
+        synchronized (lock) {
+            assertEquals(nPlatformThreads + nVirtualThreads, counter.value());
+        }
+    }
+
+    /**
+     * Test entering a monitor with JNI MonitorEnter.
+     */
+    @Test
+    void testEnterInNative() throws Exception {
+        Object lock = new Object();
+        VThreadRunner.run(() -> {
+            runWithMonitorEnteredInNative(lock, () -> {
+                assertTrue(Thread.holdsLock(lock));
+            });
+            assertFalse(Thread.holdsLock(lock));
+        });
+    }
+
+    /**
+     * Test reentering a monitor with JNI MonitorEnter.
+     */
+    @Test
+    void testReenterInNative() throws Exception {
+        Object lock = new Object();
+        VThreadRunner.run(() -> {
+
+            // enter, reenter with JNI MonitorEnter
+            synchronized (lock) {
+                runWithMonitorEnteredInNative(lock, () -> {
+                    assertTrue(Thread.holdsLock(lock));
+                });
+                assertTrue(Thread.holdsLock(lock));
+            }
+            assertFalse(Thread.holdsLock(lock));
+
+            // enter with JNI MonitorEnter, renter with synchronized statement
+            runWithMonitorEnteredInNative(lock, () -> {
+                assertTrue(Thread.holdsLock(lock));
+                synchronized (lock) {
+                    assertTrue(Thread.holdsLock(lock));
+                }
+                assertTrue(Thread.holdsLock(lock));
+            });
+            assertFalse(Thread.holdsLock(lock));
+
+            // enter with JNI MonitorEnter, renter with JNI MonitorEnter
+            runWithMonitorEnteredInNative(lock, () -> {
+                assertTrue(Thread.holdsLock(lock));
+                runWithMonitorEnteredInNative(lock, () -> {
+                    assertTrue(Thread.holdsLock(lock));
+                });
+                assertTrue(Thread.holdsLock(lock));
+            });
+            assertFalse(Thread.holdsLock(lock));
+        });
+    }
+
+    /**
+     * Test entering a monitor with JNI MonitorEnter and with contention.
+     */
+    @Test
+    void testEnterInNativeWithContention() throws Exception {
+        var lock = new Object();
+        var started = new CountDownLatch(1);
+        var entered = new AtomicBoolean();
+        var vthread = Thread.ofVirtual().unstarted(() -> {
+            started.countDown();
+            runWithMonitorEnteredInNative(lock, () -> {
+                assertTrue(Thread.holdsLock(lock));
+                entered.set(true);
+            });
+        });
+        try {
+            synchronized (lock) {
+                vthread.start();
+
+                // wait for thread to start and block
+                started.await();
+                await(vthread, Thread.State.BLOCKED);
+
+                assertFalse(entered.get());
+            }
+        } finally {
+            vthread.join();
+        }
+        assertTrue(entered.get());
+    }
+
+    /**
+     * Test entering a monitor with JNI MonitorEnter from many threads at the same time.
+     */
+    @ParameterizedTest
+    @MethodSource("threadCounts")
+    void testEnterInNativeConcurrently(int nPlatformThreads, int nVirtualThreads) throws Exception {
+        var counter = new Object() {
+            int value;
+            int value() { return value; }
+            void increment() { value++; }
+        };
+        var lock = counter;
+        executeConcurrently(nPlatformThreads, nVirtualThreads, () -> {
+            runWithMonitorEnteredInNative(lock, () -> {
+                assertTrue(Thread.holdsLock(lock));
+                counter.increment();
+                LockSupport.parkNanos(100_000_000);  // 100ms
+            });
+        });
+        synchronized (lock) {
+            assertEquals(nPlatformThreads + nVirtualThreads, counter.value());
+        }
+    }
+
+    /**
+     * Test parking with synchronized native method on stack.
+     */
+    @Test
+    void testParkingWhenPinned() throws Exception {
+        var lock = this;
+        var started = new CountDownLatch(1);
+        var entered = new AtomicBoolean();
+        var done = new AtomicBoolean();
+        var vthread = Thread.ofVirtual().start(() -> {
+            started.countDown();
+            runWithSynchronizedNative(() -> {
+                assertTrue(Thread.holdsLock(lock));
+                entered.set(true);
+                while (!done.get()) {
+                    LockSupport.park();
+                }
+            });
+        });
+        try {
+            // wait for thread to start and block
+            started.await();
+            await(vthread, Thread.State.WAITING);
+        } finally {
+            done.set(true);
+            LockSupport.unpark(vthread);
+            vthread.join();
+        }
+        assertTrue(entered.get());
+    }
+
+    /**
+     * Test blocking with synchronized native method on stack.
+     */
+    @Test
+    void testBlockingWhenPinned() throws Exception {
+        var lock1 = this;
+        var lock2 = new Object();
+
+        var started = new CountDownLatch(1);
+        var entered1 = new AtomicBoolean();   // set to true when vthread enters lock1
+        var entered2 = new AtomicBoolean();   // set to true when vthread enters lock2
+
+        var vthread = Thread.ofVirtual().unstarted(() -> {
+            started.countDown();
+            runWithSynchronizedNative(() -> {
+                assertTrue(Thread.holdsLock(lock1));
+                entered1.set(true);
+                synchronized (lock2) {   // should block
+                    assertTrue(Thread.holdsLock(lock2));
+                    entered2.set(true);
+                }
+            });
+        });
+        try {
+            synchronized (lock2) {
+                // start thread and wait for it to block trying to enter lock2
+                vthread.start();
+                started.await();
+                await(vthread, Thread.State.BLOCKED);
+
+                assertTrue(entered1.get());
+                assertFalse(entered2.get());
+            }
+        } finally {
+            vthread.join();
+        }
+        assertTrue(entered2.get());
+    }
+
+    /**
+     * Invokes the given task's run method while holding the monitor for "this".
+     */
+    private synchronized native void runWithSynchronizedNative(Runnable task);
+
+    /**
+     * Invokes the given task's run method while holding the monitor for the given
+     * object. The monitor is entered with JNI MonitorEnter, and exited with JNI MonitorExit.
+     */
+    private native void runWithMonitorEnteredInNative(Object lock, Runnable task);
+
+    /**
+     * Called from native methods to run the given task.
+     */
+    private void run(Runnable task) {
+        task.run();
+    }
+
+    /**
+     * Waits for the given thread to reach a given state.
+     */
+    private void await(Thread thread, Thread.State expectedState) throws InterruptedException {
+        Thread.State state = thread.getState();
+        while (state != expectedState) {
+            assertTrue(state != Thread.State.TERMINATED, "Thread has terminated");
+            Thread.sleep(10);
+            state = thread.getState();
+        }
+    }
+}
