diff a/test/hotspot/jtreg/runtime/cds/appcds/aotClassLinking/BulkLoaderTest.java b/test/hotspot/jtreg/runtime/cds/appcds/aotClassLinking/BulkLoaderTest.java
--- /dev/null
+++ b/test/hotspot/jtreg/runtime/cds/appcds/aotClassLinking/BulkLoaderTest.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright (c) 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.
+ *
+ */
+
+// AOT-linked classes are loaded during VM bootstrap by the C++ class AOTLinkedClassBulkLoader.
+// Make sure that the Module, Package, CodeSource and ProtectionDomain of these classes are
+// set up properly.
+
+/*
+ * @test id=static
+ * @requires vm.cds.supports.aot.class.linking
+ * @library /test/jdk/lib/testlibrary /test/lib
+ * @build InitiatingLoaderTester
+ * @build BulkLoaderTest
+ * @run driver jdk.test.lib.helpers.ClassFileInstaller -jar BulkLoaderTestApp.jar BulkLoaderTestApp MyUtil InitiatingLoaderTester
+ * @run driver BulkLoaderTest STATIC
+ */
+
+/*
+ * @test id=dynamic
+ * @requires vm.cds.supports.aot.class.linking
+ * @library /test/jdk/lib/testlibrary /test/lib
+ * @build InitiatingLoaderTester
+ * @build BulkLoaderTest
+ * @run driver jdk.test.lib.helpers.ClassFileInstaller -jar BulkLoaderTestApp.jar BulkLoaderTestApp MyUtil InitiatingLoaderTester
+ * @run driver BulkLoaderTest DYNAMIC
+ */
+
+import java.io.File;
+import java.lang.StackWalker.StackFrame;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.Set;
+import jdk.test.lib.cds.CDSAppTester;
+import jdk.test.lib.helpers.ClassFileInstaller;
+import jdk.test.lib.process.OutputAnalyzer;
+
+public class BulkLoaderTest {
+    static final String appJar = ClassFileInstaller.getJarPath("BulkLoaderTestApp.jar");
+    static final String mainClass = "BulkLoaderTestApp";
+
+    public static void main(String[] args) throws Exception {
+        test(args, true);
+        if (args[0].equals("STATIC")) {
+            test(args, false);
+        }
+    }
+
+    static void test(String[] args, boolean archivePackageOopssAndProtectionDomains) throws Exception {
+        Tester t = new Tester(archivePackageOopssAndProtectionDomains);
+
+        // Run with archived FMG loaded
+        t.run(args);
+
+        // Run with an extra classpath -- archived FMG can still load.
+        {
+            String extraVmArgs[] = {
+                "-cp",
+                appJar + File.pathSeparator + "foobar.jar"
+            };
+            OutputAnalyzer out = t.productionRun(extraVmArgs);
+            out.shouldHaveExitValue(0);
+        }
+
+        // Run without archived FMG -- fail to load
+        {
+            String extraVmArgs[] = {
+                "-Xshare:on",
+                "-Xlog:cds",
+                "-Djdk.module.showModuleResolution=true"
+            };
+            t.setCheckExitValue(false);
+            OutputAnalyzer out = t.productionRun(extraVmArgs);
+            out.shouldHaveExitValue(1);
+            out.shouldContain("CDS archive has aot-linked classes. It cannot be used when archived full module graph is not used.");
+            t.setCheckExitValue(true);
+        }
+    }
+
+    static class Tester extends CDSAppTester {
+        boolean archivePackageOopssAndProtectionDomains;
+        public Tester(boolean archivePackageOopssAndProtectionDomains) {
+            super(mainClass);
+            this.archivePackageOopssAndProtectionDomains = archivePackageOopssAndProtectionDomains;
+        }
+
+        @Override
+        public String classpath(RunMode runMode) {
+            return appJar;
+        }
+
+        @Override
+        public String[] vmArgs(RunMode runMode) {
+            String which =  archivePackageOopssAndProtectionDomains ? "+" : "-";
+            return new String[] {
+                "-Xlog:cds,cds+aot+load",
+                "-XX:+AOTClassLinking",
+                "-XX:" + which + "ArchivePackages",
+                "-XX:" + which + "ArchiveProtectionDomains"
+            };
+        }
+
+        @Override
+        public String[] appCommandLine(RunMode runMode) {
+            return new String[] {
+                mainClass,
+            };
+        }
+    }
+}
+
+class BulkLoaderTestApp {
+    static String allPerms = "null.*<no principals>.*java.security.Permissions.*,*java.security.AllPermission.*<all permissions>.*<all actions>";
+
+    public static void main(String args[]) throws Exception {
+        checkClasses();
+        checkInitiatingLoader();
+    }
+
+    // Check the ClassLoader/Module/Package/ProtectionDomain/CodeSource of classes that are aot-linked
+    static void checkClasses() throws Exception {
+        check(String.class,
+              "null",  // loader
+              "module java.base",
+              "package java.lang",
+              "null",
+              allPerms);
+
+        check(Class.forName("sun.util.logging.internal.LoggingProviderImpl"),
+              "null",
+              "module java.logging",
+              "package sun.util.logging.internal",
+              "null",
+              allPerms);
+
+
+        check(javax.tools.FileObject.class,
+              "^jdk.internal.loader.ClassLoaders[$]PlatformClassLoader@",
+              "module java.compiler",
+              "package javax.tools",
+              "jrt:/java.compiler <no signer certificates>",
+              "jdk.internal.loader.ClassLoaders[$]PlatformClassLoader.*<no principals>.*java.security.Permissions.*"
+              + "java.lang.RuntimePermission.*accessSystemModules");
+
+        check(BulkLoaderTestApp.class,
+              "jdk.internal.loader.ClassLoaders[$]AppClassLoader@",
+              "^unnamed module @",
+              "package ",
+              "file:.*BulkLoaderTestApp.jar <no signer certificates>",
+              "jdk.internal.loader.ClassLoaders[$]AppClassLoader.*<no principals>.*java.security.Permissions.*"
+              + "java.io.FilePermission.*BulkLoaderTestApp.jar.*read");
+
+        check(Class.forName("com.sun.tools.javac.Main"),
+              "jdk.internal.loader.ClassLoaders[$]AppClassLoader@",
+              "module jdk.compiler",
+              "package com.sun.tools.javac",
+              "jrt:/jdk.compiler <no signer certificates>",
+              "jdk.internal.loader.ClassLoaders[$]AppClassLoader.*<no principals>.*java.security.Permissions.*"
+              + "java.lang.RuntimePermission.*accessSystemModules");
+
+/*
+  FIXME - not working with -XX:+AOTInvokeDynamicLinking
+
+        doit(() -> {
+            Class<?> lambdaClass = MyUtil.getCallerClass(1);
+            check(lambdaClass,
+              "jdk.internal.loader.ClassLoaders[$]AppClassLoader@",
+              "unnamed module",
+              "package ",
+              "file:.*BulkLoaderTestApp.jar <no signer certificates>",
+              "jdk.internal.loader.ClassLoaders[$]AppClassLoader.*<no principals>.*java.security.Permissions.*"
+              + "java.io.FilePermission.*BulkLoaderTestApp.jar.*read");
+
+          });
+*/
+    }
+
+    static void check(Class c, String loader, String module, String pkg, String codeSource, String protectionDomain) {
+        System.out.println("====================================================================");
+        System.out.println(c.getName() + ", loader  = " + c.getClassLoader());
+        System.out.println(c.getName() + ", module  = " + c.getModule());
+        System.out.println(c.getName() + ", package = " + c.getPackage());
+        System.out.println(c.getName() + ", CS      = " + c.getProtectionDomain().getCodeSource());
+        System.out.println(c.getName() + ", PD      = " + c.getProtectionDomain());
+
+        expectMatch("" + c.getClassLoader(), loader);
+        expectMatch("" + c.getModule(), module);
+        expectSame("" + c.getPackage(), pkg);
+        expectMatch("" + c.getProtectionDomain().getCodeSource(), codeSource);
+        expectMatch("" + c.getProtectionDomain(), protectionDomain);
+    }
+
+    static void expectSame(String a, String b) {
+        if (!a.equals(b)) {
+            throw new RuntimeException("Expected \"" + b + "\" but got \"" + a + "\"");
+        }
+    }
+    static void expectMatch(String string, String pattern) {
+        Matcher matcher = Pattern.compile(pattern, Pattern.DOTALL).matcher(string);
+        if (!matcher.find()) {
+            throw new RuntimeException("Expected pattern \"" + pattern + "\" but got \"" + string + "\"");
+        }
+    }
+
+    static void doit(Runnable t) {
+        t.run();
+    }
+
+    static void checkInitiatingLoader() throws Exception {
+        try {
+            InitiatingLoaderTester.tryAccess();
+        } catch (IllegalAccessError t) {
+            if (t.getMessage().contains("cannot access class jdk.internal.misc.Unsafe (in module java.base)")) {
+                System.out.println("Expected exception:");
+                t.printStackTrace(System.out);
+                // Class.forName() should still work. We just can't resolve it in CP entries.
+                Class<?> c = Class.forName("jdk.internal.misc.Unsafe");
+                System.out.println("App loader can still resolve by name: " + c);
+                return;
+            }
+            throw new RuntimeException("Unexpected exception", t);
+        }
+
+        throw new RuntimeException("Should not have succeeded");
+    }
+}
+
+class MyUtil {
+    // depth is 0-based -- i.e., depth==0 returns the class of the immediate caller of getCallerClass
+    static Class<?> getCallerClass(int depth) {
+        // Need to add the frame of the getCallerClass -- so the immediate caller (depth==0) of this method
+        // is at stack.get(1) == stack.get(depth+1);
+        StackWalker walker = StackWalker.getInstance(
+            Set.of(StackWalker.Option.RETAIN_CLASS_REFERENCE,
+                   StackWalker.Option.SHOW_HIDDEN_FRAMES));
+        List<StackFrame> stack = walker.walk(s -> s.limit(depth+2).collect(Collectors.toList()));
+        return stack.get(depth+1).getDeclaringClass();
+    }
+}
