33import org .flossware .classloader .util .ClassNameUtil ;
44
55import java .io .ByteArrayOutputStream ;
6-
76import static org .flossware .classloader .util .ClassLoaderConstants .DEFAULT_BUFFER_SIZE ;
87import java .io .IOException ;
98import java .io .InputStream ;
@@ -188,17 +187,22 @@ private String buildJarUrl(MavenArtifact artifact) {
188187 /**
189188 * Ensures a JAR file is cached for the given artifact.
190189 * Downloads the JAR once and reuses it for subsequent class extractions.
191- * Uses per-artifact locking to allow concurrent downloads of different artifacts.
190+ *
191+ * <p>Uses a FutureTask-based per-artifact locking pattern so that downloads of different
192+ * artifacts proceed fully in parallel (no shared lock or ConcurrentHashMap bin-lock held
193+ * during I/O), while duplicate downloads of the same artifact are coalesced -- only the
194+ * first thread downloads, and subsequent threads wait on the same Future.</p>
192195 *
193196 * @param artifactKey The artifact identifier
194197 * @param jarUrl The URL to download the JAR from
195198 * @return A JarFile instance opened on the cached JAR
196199 * @throws IOException if download or JAR opening fails
197200 */
198201 private JarFile ensureJarCached (String artifactKey , String jarUrl ) throws IOException {
199- return jarFileCache .computeIfAbsent (artifactKey , key -> {
200- try {
201- // Download JAR to temp file
202+ // Create a FutureTask that encapsulates the download work
203+ FutureTask <JarFile > newTask = new FutureTask <>(new Callable <JarFile >() {
204+ @ Override
205+ public JarFile call () throws IOException {
202206 Path tempJarPath = Files .createTempFile ("jclassloader-nexus-" , ".jar" );
203207 try {
204208 downloadJarFile (jarUrl , tempJarPath );
@@ -214,12 +218,35 @@ private JarFile ensureJarCached(String artifactKey, String jarUrl) throws IOExce
214218 }
215219 throw e ;
216220 }
217- } catch (IOException e ) {
218- // Wrap IOException in RuntimeException for computeIfAbsent compatibility
219- // This will be caught and re-thrown as IOException by the caller
220- throw new UncheckedIOException (e );
221221 }
222222 });
223+
224+ // Atomically insert the task -- only the winner actually runs the download.
225+ // putIfAbsent returns null if this thread won, or the existing task otherwise.
226+ FutureTask <JarFile > existingTask = jarFileCache .putIfAbsent (artifactKey , newTask );
227+ if (existingTask == null ) {
228+ // This thread won the race -- run the download (outside any CHM lock)
229+ newTask .run ();
230+ existingTask = newTask ;
231+ }
232+
233+ try {
234+ return existingTask .get ();
235+ } catch (ExecutionException e ) {
236+ // Download failed -- remove the poisoned future so retries can try again
237+ jarFileCache .remove (artifactKey , existingTask );
238+ Throwable cause = e .getCause ();
239+ if (cause instanceof IOException ) {
240+ throw (IOException ) cause ;
241+ }
242+ throw new IOException ("Failed to download artifact: " + artifactKey , cause );
243+ } catch (InterruptedException e ) {
244+ Thread .currentThread ().interrupt ();
245+ throw new IOException ("Interrupted while waiting for artifact download: " + artifactKey , e );
246+ } catch (CancellationException e ) {
247+ jarFileCache .remove (artifactKey , existingTask );
248+ throw new IOException ("Artifact download was cancelled: " + artifactKey , e );
249+ }
223250 }
224251
225252 private void downloadJarFile (String jarUrl , Path tempJarPath ) throws IOException {
@@ -412,9 +439,16 @@ public void close() throws IOException {
412439 List <IOException > exceptions = new ArrayList <>();
413440
414441 // Close all cached JAR files
415- for (JarFile jarFile : jarFileCache .values ()) {
442+ for (FutureTask < JarFile > task : jarFileCache .values ()) {
416443 try {
417- jarFile .close ();
444+ if (task .isDone () && !task .isCancelled ()) {
445+ task .get ().close ();
446+ }
447+ } catch (ExecutionException e ) {
448+ // Task failed during download -- nothing to close
449+ } catch (InterruptedException e ) {
450+ Thread .currentThread ().interrupt ();
451+ exceptions .add (new IOException ("Interrupted during close" , e ));
418452 } catch (IOException e ) {
419453 exceptions .add (e );
420454 }
0 commit comments