Merge pull request #4589 from matthiasblaesing/multi-release

Multi-release jar support
diff --git a/.travis.yml b/.travis.yml
index 0fad142..f25454b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -198,6 +198,20 @@
             - hide-logs.sh ant $OPTS -f platform/templates test
             - hide-logs.sh ant $OPTS -f platform/templatesui test
             - hide-logs.sh ant $OPTS -f platform/uihandler test
+          
+        - name: Test platform modules on JDK 11, Batch 1
+          jdk: openjdk8
+          env:
+            - OPTS="-Dmetabuild.jsonurl=https://raw.githubusercontent.com/apache/netbeans-jenkins-lib/master/meta/netbeansrelease.json -silent -Dcluster.config=platform -Djavac.compilerargs=-nowarn -Dbuild.compiler.deprecation=false -Dtest-unit-sys-prop.ignore.random.failures=true -Dvanilla.javac.exists=true"
+          before_script:
+            - nbbuild/travis/ant.sh $OPTS clean
+            - nbbuild/travis/ant.sh $OPTS build
+            - wget https://cdn.azul.com/zulu/bin/zulu11.58.23-ca-jdk11.0.16.1-linux_x64.tar.gz
+            - tar --extract --gzip --directory $HOME -f zulu11.58.23-ca-jdk11.0.16.1-linux_x64.tar.gz
+            - TEST_JDK=$HOME/zulu11.58.23-ca-jdk11.0.16.1-linux_x64
+            - export OPTS="$OPTS  -Dtest.nbjdk.home=$TEST_JDK"
+          script:
+            - hide-logs.sh ant $OPTS -f platform/o.n.bootstrap test
 
         - name: Test ide modules
           jdk: openjdk8
@@ -441,8 +455,9 @@
           env:
             - OPTS="-Dmetabuild.jsonurl=https://raw.githubusercontent.com/apache/netbeans-jenkins-lib/master/meta/netbeansrelease.json -quiet -Dcluster.config=java -Djavac.compilerargs=-nowarn -Dbuild.compiler.deprecation=false -Dtest-unit-sys-prop.ignore.random.failures=true"
           before_script:
-            - wget https://raw.githubusercontent.com/sormuras/bach/master/install-jdk.sh
-            - export TEST_JDK=`bash install-jdk.sh --feature 11 --license GPL --emit-java-home --silent | tail -1`
+            - wget https://cdn.azul.com/zulu/bin/zulu11.58.23-ca-jdk11.0.16.1-linux_x64.tar.gz
+            - tar --extract --gzip --directory $HOME -f zulu11.58.23-ca-jdk11.0.16.1-linux_x64.tar.gz
+            - TEST_JDK=$HOME/zulu11.58.23-ca-jdk11.0.16.1-linux_x64
             - export OPTS="-Dmetabuild.jsonurl=https://raw.githubusercontent.com/apache/netbeans-jenkins-lib/master/meta/netbeansrelease.json $OPTS  -Dtest.nbjdk.home=$TEST_JDK -Dtest.run.args=--limit-modules=java.base,java.logging,java.xml,java.prefs,java.desktop,java.management,java.instrument,jdk.zipfs,java.scripting,java.naming -Dtest.bootclasspath.prepend.args=-Dno.netbeans.bootclasspath.prepend.needed=true"
             - nbbuild/travis/ant.sh $OPTS clean
             - nbbuild/travis/ant.sh $OPTS build
@@ -620,8 +635,9 @@
           before_script:
             - nbbuild/travis/ant.sh $BUILD_OPTS clean
             - nbbuild/travis/ant.sh $BUILD_OPTS build
-            - wget https://raw.githubusercontent.com/sormuras/bach/master/install-jdk.sh
-            - export TEST_JDK=`bash install-jdk.sh --feature 11 --license GPL --emit-java-home --silent | tail -1`
+            - wget https://cdn.azul.com/zulu/bin/zulu11.58.23-ca-jdk11.0.16.1-linux_x64.tar.gz
+            - tar --extract --gzip --directory $HOME -f zulu11.58.23-ca-jdk11.0.16.1-linux_x64.tar.gz
+            - TEST_JDK=$HOME/zulu11.58.23-ca-jdk11.0.16.1-linux_x64
             - export OPTS="-Dmetabuild.jsonurl=https://raw.githubusercontent.com/apache/netbeans-jenkins-lib/master/meta/netbeansrelease.json $OPTS  -Dtest.nbjdk.home=$TEST_JDK -Dtest.run.args=--limit-modules=java.base,java.logging,java.xml,java.prefs,java.desktop,java.management,java.instrument,jdk.zipfs,java.scripting,java.naming -Dtest.bootclasspath.prepend.args=-Dno.netbeans.bootclasspath.prepend.needed=true"
           script:
             #- ant $TEST_OPTS -f groovy/groovy test
diff --git a/ide/c.jcraft.jsch/build.xml b/ide/c.jcraft.jsch/build.xml
index a7252c9..d06dfb5 100644
--- a/ide/c.jcraft.jsch/build.xml
+++ b/ide/c.jcraft.jsch/build.xml
@@ -33,6 +33,7 @@
                 <!-- Ensure that the necessary modules/bundles are made available to JSch -->
                 <attribute name="Require-Bundle" value="com.jcraft.jzlib,bcprov,libs.c.kohlschutter.junixsocket,org.netbeans.libs.jna,org.netbeans.libs.jna.platform"/>
                 <attribute name="NB-Original-CRC" value="${c.jcraft.jsch.crc32}"/>
+                <attribute name="Multi-Release" value="true"/>
             </manifest>
         </jar>
     </target>
diff --git a/platform/netbinox/nbproject/project.properties b/platform/netbinox/nbproject/project.properties
index dfa06c9..dc24e53 100644
--- a/platform/netbinox/nbproject/project.properties
+++ b/platform/netbinox/nbproject/project.properties
@@ -17,7 +17,7 @@
 
 is.autoload=true
 release.external/org.eclipse.osgi_3.9.1.nb9.jar=modules/ext/org.eclipse.osgi_3.9.1.nb9.jar
-javac.source=1.6
+javac.source=1.8
 javac.target=1.8
 javac.compilerargs=-Xlint -Xlint:-serial
 
diff --git a/platform/netbinox/nbproject/project.xml b/platform/netbinox/nbproject/project.xml
index e123c78..da9a30a 100644
--- a/platform/netbinox/nbproject/project.xml
+++ b/platform/netbinox/nbproject/project.xml
@@ -92,6 +92,11 @@
                         <recursive/>
                         <compile-dependency/>
                     </test-dependency>
+                    <test-dependency>
+                        <code-name-base>org.openide.util.ui</code-name-base>
+                        <compile-dependency/>
+                        <test/>
+                    </test-dependency>
                 </test-type>
             </test-dependencies>
             <public-packages>
diff --git a/platform/netbinox/src/org/netbeans/modules/netbinox/JarBundleFile.java b/platform/netbinox/src/org/netbeans/modules/netbinox/JarBundleFile.java
index eb916cc..077ccf2 100644
--- a/platform/netbinox/src/org/netbeans/modules/netbinox/JarBundleFile.java
+++ b/platform/netbinox/src/org/netbeans/modules/netbinox/JarBundleFile.java
@@ -30,17 +30,21 @@
 import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.jar.Attributes.Name;
+import java.util.jar.Manifest;
 import java.util.logging.Level;
 import org.eclipse.osgi.baseadaptor.BaseData;
 import org.eclipse.osgi.baseadaptor.bundlefile.BundleEntry;
 import org.eclipse.osgi.baseadaptor.bundlefile.BundleFile;
 import org.eclipse.osgi.baseadaptor.bundlefile.DirBundleFile;
-import org.eclipse.osgi.baseadaptor.bundlefile.DirZipBundleEntry;
 import org.eclipse.osgi.baseadaptor.bundlefile.MRUBundleFileList;
 import org.eclipse.osgi.baseadaptor.bundlefile.ZipBundleFile;
 import org.netbeans.core.netigso.spi.BundleContent;
 import org.netbeans.core.netigso.spi.NetigsoArchive;
 import org.openide.modules.ModuleInfo;
+import org.openide.util.Exceptions;
 import org.openide.util.Lookup;
 
 /** This is fake bundle. It is created by the Netbinox infrastructure to 
@@ -49,14 +53,36 @@
  * @author Jaroslav Tulach <jtulach@netbeans.org>
  */
 final class JarBundleFile extends BundleFile implements BundleContent {
-    private BundleFile delegate;
-
+    //
+    // When making changes to this file, check if
+    // platform/o.n.bootstrap/src/org/netbeans/JarClassLoader.java (JarClassLoader/JarSource)
+    // should also be adjusted. At least the multi-release handling is similar.
+    //
+    private static final String META_INF = "META-INF/";
+    private static final Name MULTI_RELEASE = new Name("Multi-Release");
+    private static final int BASE_VERSION = 8;
+    private static final int RUNTIME_VERSION;
     private static Map<Long,File> usedIds;
 
+    static {
+        int version;
+        try {
+            Object runtimeVersion = Runtime.class.getMethod("version").invoke(null);
+            version = (int) runtimeVersion.getClass().getMethod("major").invoke(runtimeVersion);
+        } catch (ReflectiveOperationException ex) {
+            version = BASE_VERSION;
+        }
+        RUNTIME_VERSION = version;
+    }
+
+    private BundleFile delegate;
+
     private final MRUBundleFileList mru;
     private final BaseData data;
     private final NetigsoArchive archive;
-    
+    private int[] versions;
+    private Boolean isMultiRelease;
+
     JarBundleFile(
         File base, BaseData data, NetigsoArchive archive,
         MRUBundleFileList mru, boolean isBase
@@ -171,6 +197,18 @@
 
     @Override
     public File getFile(String file, boolean bln) {
+        if (((! file.startsWith(META_INF)) ) && isMultiRelease()) {
+            for (int version : getVersions()) {
+                File f = getFile0("META-INF/versions/" + version + "/" + file, bln);
+                if (f != null) {
+                    return f;
+                }
+            }
+        }
+        return getFile0(file, bln);
+    }
+
+    private File getFile0(String file, boolean bln) {
         byte[] exists = getCachedEntry(file);
         if (exists == null) {
             return null;
@@ -181,6 +219,18 @@
 
     @Override
     public byte[] resource(String name) throws IOException {
+        if ((! name.startsWith(META_INF)) && isMultiRelease()) {
+            for (int version : getVersions()) {
+                byte[] b = resource0("META-INF/versions/" + version + "/" + name);
+                if (b != null) {
+                    return b;
+                }
+            }
+        }
+        return resource0(name);
+    }
+
+    private byte[] resource0(String name) throws IOException {
         BundleEntry u = findEntry("resource", name);
         if (u == null) {
             return null;
@@ -262,6 +312,18 @@
 
     @Override
     public BundleEntry getEntry(final String name) {
+        if ((! name.startsWith(META_INF)) && isMultiRelease()) {
+            for (int version : getVersions()) {
+                BundleEntry be = getEntry0("META-INF/versions/" + version + "/" + name);
+                if(be != null) {
+                    return be;
+                }
+            }
+        }
+        return getEntry0(name);
+    }
+
+    private BundleEntry getEntry0(final String name) {
         if (!archive.isActive()) {
             return delegate("inactive", name).getEntry(name); // NOI18N
         }
@@ -351,4 +413,50 @@
             return findEntry("getFileURL", name).getFileURL(); // NOI18N
         }
     }
+
+    /**
+     * @return versions for which a {@code META-INF/versions/NUMBER} entry exists.
+     * The order is from largest version to lowest. Only versions supported by
+     * the runtime VM are reported.
+     */
+    private int[] getVersions() {
+        if (versions != null) {
+            return versions;
+        }
+
+        Set<Integer> vers = new TreeSet<>(Collections.reverseOrder());
+        for(int i = BASE_VERSION; i <= RUNTIME_VERSION; i++) {
+            String directory = "META-INF/versions/" + i;
+            BundleEntry be = delegate("getVersions", directory).getEntry(directory);
+            if (be != null) {
+                vers.add(i);
+            }
+        }
+        int[] ret = new int[vers.size()];
+        int i = 0;
+        for (Integer ver : vers) {
+            ret[i++] = ver;
+        }
+        versions = ret;
+        return versions;
+    }
+
+    private boolean isMultiRelease() {
+        if(isMultiRelease != null) {
+            return isMultiRelease;
+        }
+        BundleEntry be = delegate("isMultiRelease", "META-INF/MANIFEST.MF").getEntry("META-INF/MANIFEST.MF");
+        if(be == null) {
+            isMultiRelease = false;
+        } else {
+            try {
+                Manifest manifest = new Manifest(be.getInputStream());
+                isMultiRelease = Boolean.valueOf(manifest.getMainAttributes().getValue(MULTI_RELEASE));
+            } catch (IOException ex) {
+                Exceptions.printStackTrace(ex);
+            }
+
+        }
+        return isMultiRelease;
+    }
 }
diff --git a/platform/netbinox/src/org/netbeans/modules/netbinox/NetbinoxLoader.java b/platform/netbinox/src/org/netbeans/modules/netbinox/NetbinoxLoader.java
index bfeefde..34c0fae 100644
--- a/platform/netbinox/src/org/netbeans/modules/netbinox/NetbinoxLoader.java
+++ b/platform/netbinox/src/org/netbeans/modules/netbinox/NetbinoxLoader.java
@@ -20,8 +20,6 @@
 
 import java.io.File;
 import java.io.IOException;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.security.ProtectionDomain;
@@ -34,7 +32,6 @@
 import org.eclipse.osgi.baseadaptor.loader.ClasspathManager;
 import org.eclipse.osgi.framework.adaptor.ClassLoaderDelegate;
 import org.eclipse.osgi.internal.baseadaptor.DefaultClassLoader;
-import org.openide.util.Exceptions;
 import org.osgi.framework.FrameworkEvent;
 
 /** Classloader that eliminates some unnecessary disk touches.
diff --git a/platform/netbinox/test/unit/src/org/netbeans/modules/netbinox/NetbinoxMultiversionJarTest.java b/platform/netbinox/test/unit/src/org/netbeans/modules/netbinox/NetbinoxMultiversionJarTest.java
new file mode 100644
index 0000000..f186ea4
--- /dev/null
+++ b/platform/netbinox/test/unit/src/org/netbeans/modules/netbinox/NetbinoxMultiversionJarTest.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.netbinox;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import javax.tools.JavaFileObject;
+import javax.tools.SimpleJavaFileObject;
+import javax.tools.ToolProvider;
+import org.netbeans.MockEvents;
+import org.netbeans.MockModuleInstaller;
+import org.netbeans.ModuleManager;
+import org.openide.util.test.TestFileUtils;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static junit.framework.TestCase.assertEquals;
+
+public class NetbinoxMultiversionJarTest extends NetigsoHid {
+
+    public NetbinoxMultiversionJarTest(String name) {
+        super(name);
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        Locale.setDefault(new Locale("te", "ST"));
+        clearWorkDir();
+        File ud = new File(getWorkDir(), "ud");
+        ud.mkdirs();
+        System.setProperty("netbeans.user", ud.getPath());
+
+        data = new File(getDataDir(), "jars");
+        jars = new File(getWorkDir(), "space in path");
+        jars.mkdirs();
+
+        File classes = new File(getWorkDir(), "classes");
+        classes.mkdirs();
+        ToolProvider.getSystemJavaCompiler()
+                .getTask(null, null, d -> {
+                    throw new IllegalStateException(d.toString());
+                }, Arrays.asList("-d", classes.getAbsolutePath()), null,
+                        Arrays.asList(new SourceFileObject("test/Impl.java", "package test; public class Impl { public static String get() { return \"base\"; } }"),
+                                new SourceFileObject("api/API.java", "package api; public class API { public static String run() { return test.Impl.get(); } }")))
+                .call();
+        File classes9 = new File(new File(new File(classes, "META-INF"), "versions"), "9");
+        classes9.mkdirs();
+        ToolProvider.getSystemJavaCompiler()
+                .getTask(null, null, d -> {
+                    throw new IllegalStateException(d.toString());
+                }, Arrays.asList("-d", classes9.getAbsolutePath(), "-classpath", classes.getAbsolutePath()), null,
+                        Arrays.asList(new SourceFileObject("test/Impl.java", "package test; public class Impl { public static String get() { return \"9\"; } }")))
+                .call();
+        Map<String, byte[]> jarContent = new LinkedHashMap<>();
+        String manifest
+                = "Manifest-Version: 1.0\n"
+                + "Bundle-SymbolicName: test.module\n"
+                + "Bundle-Version: 1.0\n"
+                + "Multi-Release: true\n"
+                + "";
+        jarContent.put("META-INF/MANIFEST.MF", manifest.getBytes(UTF_8));
+        Path classesPath = classes.toPath();
+        Files.walk(classesPath)
+                .filter(p -> Files.isRegularFile(p))
+                .forEach(p -> {
+                    try {
+                        jarContent.put(classesPath.relativize(p).toString(), TestFileUtils.readFileBin(p.toFile()));
+                    } catch (IOException ex) {
+                        throw new IllegalStateException(ex);
+                    }
+                });
+        jarContent.put("test/dummy.txt", "base".getBytes(UTF_8));
+        jarContent.put("META-INF/versions/9/test/dummy.txt", "9".getBytes(UTF_8));
+        simpleModule = new File(jars, "multi-release.jar");
+        try ( OutputStream out = new FileOutputStream(simpleModule)) {
+            TestFileUtils.writeZipFile(out, jarContent);
+        }
+    }
+
+    public void testMultiReleaseJar() throws Exception {
+        MockModuleInstaller installer = new MockModuleInstaller();
+        MockEvents ev = new MockEvents();
+        ModuleManager mgr = new ModuleManager(installer, ev);
+        mgr.mutexPrivileged().enterWriteAccess();
+        Set<org.netbeans.Module> all = null;
+        try {
+            org.netbeans.Module m1 = mgr.create(simpleModule, null, false, false, false);
+            all = Collections.singleton(m1);
+
+            mgr.enable(all);
+
+            // Check multi release class loading
+            Class<?> impl = m1.getClassLoader().loadClass("test.Impl");
+            Method get = impl.getMethod("get");
+            String output = (String) get.invoke(null);
+
+            String expected;
+            try {
+                Class.forName("java.lang.Runtime$Version");
+                expected = "9";
+            } catch (ClassNotFoundException ex) {
+                expected = "base";
+            }
+            assertEquals(expected, output);
+
+            // Check multi release resource loading
+            try(InputStream is = m1.getClassLoader().getResourceAsStream("test/dummy.txt")) {
+                assertEquals(expected, loadUTF8(is));
+            }
+
+        } finally {
+            if (all != null) {
+                mgr.disable(all);
+            }
+            mgr.mutexPrivileged().exitWriteAccess();
+        }
+
+    }
+
+    private static String loadUTF8(InputStream is) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        byte[] buffer = new byte[2048];
+        int read;
+        while ((read = is.read(buffer)) > 0) {
+            baos.write(buffer, 0, read);
+        }
+        return baos.toString("UTF-8");
+    }
+
+    private static final class SourceFileObject extends SimpleJavaFileObject {
+
+        private final String content;
+
+        public SourceFileObject(String path, String content) throws URISyntaxException {
+            super(new URI("mem://" + path), JavaFileObject.Kind.SOURCE);
+            this.content = content;
+        }
+
+        @Override
+        public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
+            return content;
+        }
+
+    }
+}
diff --git a/platform/o.n.bootstrap/src/org/netbeans/JarClassLoader.java b/platform/o.n.bootstrap/src/org/netbeans/JarClassLoader.java
index 33a1399..7a238bc 100644
--- a/platform/o.n.bootstrap/src/org/netbeans/JarClassLoader.java
+++ b/platform/o.n.bootstrap/src/org/netbeans/JarClassLoader.java
@@ -30,6 +30,7 @@
 import java.lang.instrument.IllegalClassFormatException;
 import java.lang.ref.Reference;
 import java.lang.ref.SoftReference;
+import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.net.JarURLConnection;
 import java.net.MalformedURLException;
@@ -53,12 +54,14 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.Vector;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 import java.util.concurrent.FutureTask;
 import java.util.jar.Attributes;
+import java.util.jar.Attributes.Name;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
@@ -76,7 +79,29 @@
  * @author  Petr Nejedly
  */
 public class JarClassLoader extends ProxyClassLoader {
+    //
+    // When making changes to this file, check if
+    // platform/netbinox/src/org/netbeans/modules/netbinox/JarBundleFile.java
+    // should also be adjusted. At least the multi-release handling is similar.
+    //
+    
     private static Stamps cache;
+    private static final String META_INF = "META-INF/";
+    private static final Name MULTI_RELEASE = new Name("Multi-Release");
+    private static final int BASE_VERSION = 8;
+    private static final int RUNTIME_VERSION;
+
+    static {
+        int version;
+        try {
+            Object runtimeVersion = Runtime.class.getMethod("version").invoke(null);
+            version = (int) runtimeVersion.getClass().getMethod("major").invoke(runtimeVersion);
+        } catch (ReflectiveOperationException ex) {
+            version = BASE_VERSION;
+        }
+        RUNTIME_VERSION = version;
+    }
+    
     static Archive archive = new Archive(); 
 
     static void initializeCache() {
@@ -272,6 +297,7 @@
                     }
                 }
                 Manifest man = new DelayedManifest();
+
                 try {
                     definePackage(pkgName, man, src.getURL());
                 } catch (IllegalArgumentException x) {
@@ -336,6 +362,7 @@
         private ProtectionDomain pd;
         protected JarClassLoader jcl;
         private static Map<String,Source> sources = new HashMap<String, Source>();
+        private Boolean multiRelease;
         
         public Source(URL url) {
             this.url = url;
@@ -412,6 +439,23 @@
             return url.toString();
         }
 
+        protected boolean isMultiRelease() {
+            Manifest man = getManifest();
+            if(man == null) {
+                return false;
+            }
+            if(multiRelease != null) {
+                return multiRelease;
+            }
+            if (man.getMainAttributes().containsKey(MULTI_RELEASE)) {
+                String multiReleaseString = (String) man.getMainAttributes().get(MULTI_RELEASE);
+                multiRelease = Boolean.valueOf(multiReleaseString);
+            } else {
+                multiRelease = false;
+            }
+            return multiRelease;
+        }
+
     }
     
     static void dumpFiles(File f, int retry) {
@@ -442,6 +486,7 @@
         private boolean dead;
         private int requests;
         private int used;
+        private volatile int[] versions;
         private volatile Reference<Manifest> manifest;
         /** #141110: expensive to repeatedly look for them */
         private final Set<String> nonexistentResources = Collections.synchronizedSet(new HashSet<String>());
@@ -574,13 +619,56 @@
         @Override
         protected byte[] readClass(String path) throws IOException {
             try {
+                if ((! path.startsWith(META_INF)) && isMultiRelease() && RUNTIME_VERSION > BASE_VERSION) {
+                    int[] vers = getVersions();
+                    for (int version: vers) {
+                        byte[] data = archive.getData(this, "META-INF/versions/" + version + "/" + path);
+                        if (data != null) {
+                            return data;
+                        }
+                    }
+                }
                 return archive.getData(this, path);
             } catch (ZipException ex) {
                 dumpFiles(file, -1);
                 throw ex;
             }
         }
-        
+
+        /**
+         * @return versions for which a {@code META-INF/versions/NUMBER} entry exists.
+         * The order is from largest version to lowest. Only versions supported by
+         * the runtime VM are reported.
+         */
+        private int[] getVersions() {
+            if (versions != null) {
+                return versions;
+            }
+            try {
+                Set<Integer> vers = new TreeSet<>(Collections.reverseOrder());
+                for(int i = BASE_VERSION; i <= RUNTIME_VERSION; i++) {
+                    String directory = "META-INF/versions/" + i;
+                    byte[] data = archive.getData(this, directory);
+                    if (data != null && data.length == 0) {
+                        vers.add(i);
+                    }
+                }
+                int[] ret = new int[vers.size()];
+                int i = 0;
+                for (Integer ver : vers) {
+                    ret[i++] = ver;
+                }
+                versions = ret;
+                return ret;
+            } catch (IOException ioe) {
+                if (warnedFiles.add(file)) {
+                    LOGGER.log(Level.WARNING, "problems with " + file, ioe);
+                    dumpFiles(file, -1);
+                }
+            }
+            return new int[0];
+        }
+
         @Override
         public byte[] resource(String path) throws IOException {
             if (nonexistentResources.contains(path)) {
diff --git a/platform/o.n.bootstrap/test/unit/src/org/netbeans/JarClassLoaderTest.java b/platform/o.n.bootstrap/test/unit/src/org/netbeans/JarClassLoaderTest.java
index 2306cba..cc6e36f 100644
--- a/platform/o.n.bootstrap/test/unit/src/org/netbeans/JarClassLoaderTest.java
+++ b/platform/o.n.bootstrap/test/unit/src/org/netbeans/JarClassLoaderTest.java
@@ -18,15 +18,23 @@
  */
 package org.netbeans;
 
+import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.File;
+import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.io.Serializable;
+import java.lang.reflect.Method;
 import java.net.JarURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLClassLoader;
 import java.net.URLConnection;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Collections;
 import java.util.Enumeration;
 import java.util.List;
@@ -37,13 +45,19 @@
 import java.util.logging.Level;
 import java.util.logging.Logger;
 import java.security.Permission;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
 import java.util.Map;
+import javax.tools.SimpleJavaFileObject;
+import javax.tools.ToolProvider;
 import junit.framework.AssertionFailedError;
 import org.netbeans.junit.NbTestCase;
 import org.openide.util.Utilities;
 import org.openide.util.lookup.Lookups;
 import org.openide.util.test.TestFileUtils;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 /** Tests that cover some basic aspects of a Proxy/JarClassLoader.
  *
  * @author Petr Nejedly
@@ -414,4 +428,86 @@
 
         public @Override void checkPermission(Permission perm, Object ctx) {}
     }
+
+    public void testMultiReleaseJar() throws Exception {
+        clearWorkDir();
+
+        // Prepare multi-release jar file
+        File classes = new File(getWorkDir(), "classes");
+        classes.mkdirs();
+        ToolProvider.getSystemJavaCompiler()
+                    .getTask(null, null, d -> { throw new IllegalStateException(d.toString()); }, Arrays.asList("-d", classes.getAbsolutePath()), null,
+                             Arrays.asList(new SourceFileObject("test/Impl.java", "package test; public class Impl { public static String get() { return \"base\"; } }"),
+                                           new SourceFileObject("api/API.java", "package api; public class API { public static String run() { return test.Impl.get(); } }")))
+                    .call();
+        File classes9 = new File(new File(new File(classes, "META-INF"), "versions"), "9");
+        classes9.mkdirs();
+        ToolProvider.getSystemJavaCompiler()
+                    .getTask(null, null, d -> { throw new IllegalStateException(d.toString()); }, Arrays.asList("-d", classes9.getAbsolutePath(), "-classpath", classes.getAbsolutePath()), null,
+                             Arrays.asList(new SourceFileObject("test/Impl.java", "package test; public class Impl { public static String get() { return \"9\"; } }")))
+                    .call();
+        Map<String, byte[]> jarContent = new LinkedHashMap<>();
+        jarContent.put("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\nMulti-Release: true\n\n".getBytes());
+        Path classesPath = classes.toPath();
+        Files.walk(classesPath)
+             .filter(p -> Files.isRegularFile(p))
+             .forEach(p -> {
+                  try {
+                      jarContent.put(classesPath.relativize(p).toString(), TestFileUtils.readFileBin(p.toFile()));
+                  } catch (IOException ex) {
+                      throw new IllegalStateException(ex);
+                  }
+             });
+        jarContent.put("test/dummy.txt", "base".getBytes(UTF_8));
+        jarContent.put("META-INF/versions/9/test/dummy.txt", "9".getBytes(UTF_8));
+        File jar = new File(getWorkDir(), "multi-release.jar");
+        try (OutputStream out = new FileOutputStream(jar)) {
+            TestFileUtils.writeZipFile(out, jarContent);
+        }
+
+        // Check multi release class loading
+        JarClassLoader jcl = new JarClassLoader(Arrays.asList(jar), new ProxyClassLoader[0]);
+        Class<?> api = jcl.loadClass("api.API");
+        Method run = api.getDeclaredMethod("run");
+        String output = (String) run.invoke(null);
+        String expected;
+        try {
+            Class.forName("java.lang.Runtime$Version");
+            expected = "9";
+        } catch (ClassNotFoundException ex) {
+            expected = "base";
+        }
+        assertEquals(expected, output);
+
+        // Check multi release resource loading
+        try(InputStream is = jcl.getResourceAsStream("test/dummy.txt")) {
+            assertEquals(expected, loadUTF8(is));
+        }
+    }
+
+    private static String loadUTF8(InputStream is) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        byte[] buffer = new byte[2048];
+        int read;
+        while ((read = is.read(buffer)) > 0) {
+            baos.write(buffer, 0, read);
+        }
+        return baos.toString("UTF-8");
+    }
+
+    private static final class SourceFileObject extends SimpleJavaFileObject {
+
+        private final String content;
+
+        public SourceFileObject(String path, String content) throws URISyntaxException {
+            super(new URI("mem://" + path), Kind.SOURCE);
+            this.content = content;
+        }
+
+        @Override
+        public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
+            return content;
+        }
+
+    }
 }