Skip to content

Commit ff4b32b

Browse files
committed
Replace read/write lock in JarResource to avoid virtual threads pinning
1 parent 7e98e77 commit ff4b32b

File tree

4 files changed

+330
-128
lines changed

4 files changed

+330
-128
lines changed
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package io.quarkus.bootstrap.runner;
2+
3+
import static io.quarkus.bootstrap.runner.VirtualThreadSupport.isVirtualThread;
4+
5+
import java.io.IOException;
6+
import java.nio.file.Path;
7+
import java.util.concurrent.CompletableFuture;
8+
import java.util.concurrent.atomic.AtomicInteger;
9+
import java.util.jar.JarFile;
10+
11+
import io.smallrye.common.io.jar.JarFiles;
12+
13+
public class JarFileReference {
14+
// Guarded by an atomic reader counter that emulate the behaviour of a read/write lock.
15+
// To enable virtual threads compatibility and avoid pinning it is not possible to use an explicit read/write lock
16+
// because the jarFile access may happen inside a native call (for example triggered by the RunnerClassLoader)
17+
// and then it is necessary to avoid blocking on it.
18+
private final JarFile jarFile;
19+
20+
// The referenceCounter - 1 represents the number of effective readers (#aqcuire - #release), while the first
21+
// reference is used to determine if a close has been required.
22+
// The JarFileReference is created as already acquired and that's why the referenceCounter starts from 2
23+
private final AtomicInteger referenceCounter = new AtomicInteger(2);
24+
25+
private JarFileReference(JarFile jarFile) {
26+
this.jarFile = jarFile;
27+
}
28+
29+
/**
30+
* Increase the readers counter of the jarFile.
31+
*
32+
* @return true if the acquiring succeeded: it's now safe to access and use the inner jarFile.
33+
* false if the jar reference is going to be closed and then no longer usable.
34+
*/
35+
private boolean acquire() {
36+
while (true) {
37+
int count = referenceCounter.get();
38+
if (count == 0) {
39+
return false;
40+
}
41+
if (referenceCounter.compareAndSet(count, count + 1)) {
42+
return true;
43+
}
44+
}
45+
}
46+
47+
/**
48+
* Decrease the readers counter of the jarFile.
49+
* If the counter drops to 0 and a release has been requested also closes the jarFile.
50+
*
51+
* @return true if the release also closes the underlying jarFile.
52+
*/
53+
private boolean release(JarResource jarResource) {
54+
while (true) {
55+
int count = referenceCounter.get();
56+
if (count <= 0) {
57+
throw new IllegalStateException(
58+
"The reference counter cannot be negative, found: " + (referenceCounter.get() - 1));
59+
}
60+
if (referenceCounter.compareAndSet(count, count - 1)) {
61+
if (count == 1) {
62+
try {
63+
jarFile.close();
64+
} catch (IOException e) {
65+
// ignore
66+
} finally {
67+
jarResource.jarFileReference.set(null);
68+
}
69+
return true;
70+
}
71+
return false;
72+
}
73+
}
74+
}
75+
76+
/**
77+
* Ask to close this reference.
78+
* If there are no readers currently accessing the jarFile also close it, otherwise defer the closing when the last reader
79+
* will leave.
80+
*/
81+
void close(JarResource jarResource) {
82+
release(jarResource);
83+
}
84+
85+
@FunctionalInterface
86+
interface JarFileConsumer<T> {
87+
T apply(JarFile jarFile, Path jarPath, String resource);
88+
}
89+
90+
static <T> T withJarFile(JarResource jarResource, String resource, JarFileConsumer<T> fileConsumer) {
91+
92+
// Happy path: the jar reference already exists and it's ready to be used
93+
final var localJarFileRefFuture = jarResource.jarFileReference.get();
94+
if (localJarFileRefFuture != null && localJarFileRefFuture.isDone()) {
95+
JarFileReference jarFileReference = localJarFileRefFuture.join();
96+
if (jarFileReference.acquire()) {
97+
return consumeSharedJarFile(jarFileReference, jarResource, resource, fileConsumer);
98+
}
99+
}
100+
101+
// There's no valid jar reference, so load a new one
102+
103+
// Platform threads can load the jarfile asynchronously and eventually blocking till not ready
104+
// to avoid loading the same jarfile multiple times in parallel
105+
if (!isVirtualThread()) {
106+
// It's ok to eventually block on a join() here since we're sure this is used only by platform thread
107+
return consumeSharedJarFile(asyncLoadAcquiredJarFile(jarResource).join(), jarResource, resource, fileConsumer);
108+
}
109+
110+
// Virtual threads needs to load the jarfile synchronously to avoid blocking. This means that eventually
111+
// multiple threads could load the same jarfile in parallel and this duplication has to be reconciled
112+
final var newJarFileRef = syncLoadAcquiredJarFile(jarResource);
113+
if (jarResource.jarFileReference.compareAndSet(localJarFileRefFuture, newJarFileRef) ||
114+
jarResource.jarFileReference.compareAndSet(null, newJarFileRef)) {
115+
// The new file reference has been successfully published and can be used by the current and other threads
116+
// The join() cannot be blocking here since the CompletableFuture has been created already completed
117+
return consumeSharedJarFile(newJarFileRef.join(), jarResource, resource, fileConsumer);
118+
}
119+
120+
// The newly created file reference hasn't been published, so it can be used exclusively by the current virtual thread
121+
return consumeUnsharedJarFile(newJarFileRef, jarResource, resource, fileConsumer);
122+
}
123+
124+
private static <T> T consumeSharedJarFile(JarFileReference jarFileReference,
125+
JarResource jarResource, String resource, JarFileConsumer<T> fileConsumer) {
126+
try {
127+
return fileConsumer.apply(jarFileReference.jarFile, jarResource.jarPath, resource);
128+
} finally {
129+
jarFileReference.release(jarResource);
130+
}
131+
}
132+
133+
private static <T> T consumeUnsharedJarFile(CompletableFuture<JarFileReference> jarFileReferenceFuture,
134+
JarResource jarResource, String resource, JarFileConsumer<T> fileConsumer) {
135+
JarFileReference jarFileReference = jarFileReferenceFuture.join();
136+
try {
137+
return fileConsumer.apply(jarFileReference.jarFile, jarResource.jarPath, resource);
138+
} finally {
139+
boolean closed = jarFileReference.release(jarResource);
140+
assert !closed;
141+
// Check one last time if the file reference can be published and reused by other threads, otherwise close it
142+
if (!jarResource.jarFileReference.compareAndSet(null, jarFileReferenceFuture)) {
143+
closed = jarFileReference.release(jarResource);
144+
assert closed;
145+
}
146+
}
147+
}
148+
149+
private static CompletableFuture<JarFileReference> syncLoadAcquiredJarFile(JarResource jarResource) {
150+
try {
151+
return CompletableFuture.completedFuture(new JarFileReference(JarFiles.create(jarResource.jarPath.toFile())));
152+
} catch (IOException e) {
153+
throw new RuntimeException("Failed to open " + jarResource.jarPath, e);
154+
}
155+
}
156+
157+
private static CompletableFuture<JarFileReference> asyncLoadAcquiredJarFile(JarResource jarResource) {
158+
CompletableFuture<JarFileReference> newJarFileRef = new CompletableFuture<>();
159+
do {
160+
if (jarResource.jarFileReference.compareAndSet(null, newJarFileRef)) {
161+
try {
162+
newJarFileRef.complete(new JarFileReference(JarFiles.create(jarResource.jarPath.toFile())));
163+
return newJarFileRef;
164+
} catch (IOException e) {
165+
throw new RuntimeException(e);
166+
}
167+
}
168+
newJarFileRef = jarResource.jarFileReference.get();
169+
} while (newJarFileRef == null || !newJarFileRef.join().acquire());
170+
return newJarFileRef;
171+
}
172+
}

independent-projects/bootstrap/runner/src/main/java/io/quarkus/bootstrap/runner/JarResource.java

Lines changed: 46 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -11,44 +11,28 @@
1111
import java.security.ProtectionDomain;
1212
import java.security.cert.Certificate;
1313
import java.util.Objects;
14-
import java.util.concurrent.locks.Lock;
15-
import java.util.concurrent.locks.ReadWriteLock;
16-
import java.util.concurrent.locks.ReentrantReadWriteLock;
14+
import java.util.concurrent.CompletableFuture;
15+
import java.util.concurrent.atomic.AtomicReference;
1716
import java.util.jar.JarEntry;
1817
import java.util.jar.JarFile;
1918
import java.util.zip.ZipEntry;
20-
import java.util.zip.ZipFile;
2119

2220
import io.smallrye.common.io.jar.JarEntries;
23-
import io.smallrye.common.io.jar.JarFiles;
2421

2522
/**
2623
* A jar resource
2724
*/
2825
public class JarResource implements ClassLoadingResource {
2926

30-
private final ManifestInfo manifestInfo;
31-
private final Path jarPath;
32-
33-
private final Lock readLock;
34-
private final Lock writeLock;
35-
3627
private volatile ProtectionDomain protectionDomain;
28+
private final ManifestInfo manifestInfo;
3729

38-
//Guarded by the read/write lock; open/close operations on the JarFile require the exclusive lock,
39-
//while using an existing open reference can use the shared lock.
40-
//If a lock is acquired, and as long as it's owned, we ensure that the zipFile reference
41-
//points to an open JarFile instance, and read operations are valid.
42-
//To close the jar, the exclusive lock must be owned, and reference will be set to null before releasing it.
43-
//Likewise, opening a JarFile requires the exclusive lock.
44-
private volatile JarFile zipFile;
30+
final Path jarPath;
31+
final AtomicReference<CompletableFuture<JarFileReference>> jarFileReference = new AtomicReference<>();
4532

4633
public JarResource(ManifestInfo manifestInfo, Path jarPath) {
4734
this.manifestInfo = manifestInfo;
4835
this.jarPath = jarPath;
49-
final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
50-
this.readLock = readWriteLock.readLock();
51-
this.writeLock = readWriteLock.writeLock();
5236
}
5337

5438
@Override
@@ -69,38 +53,48 @@ public void init() {
6953

7054
@Override
7155
public byte[] getResourceData(String resource) {
72-
final ZipFile zipFile = readLockAcquireAndGetJarReference();
73-
try {
74-
ZipEntry entry = zipFile.getEntry(resource);
56+
return JarFileReference.withJarFile(this, resource, JarResourceDataProvider.INSTANCE);
57+
}
58+
59+
private static class JarResourceDataProvider implements JarFileReference.JarFileConsumer<byte[]> {
60+
private static final JarResourceDataProvider INSTANCE = new JarResourceDataProvider();
61+
62+
@Override
63+
public byte[] apply(JarFile jarFile, Path path, String res) {
64+
ZipEntry entry = jarFile.getEntry(res);
7565
if (entry == null) {
7666
return null;
7767
}
78-
try (InputStream is = zipFile.getInputStream(entry)) {
68+
try (InputStream is = jarFile.getInputStream(entry)) {
7969
byte[] data = new byte[(int) entry.getSize()];
8070
int pos = 0;
8171
int rem = data.length;
8272
while (rem > 0) {
8373
int read = is.read(data, pos, rem);
8474
if (read == -1) {
85-
throw new RuntimeException("Failed to read all data for " + resource);
75+
throw new RuntimeException("Failed to read all data for " + res);
8676
}
8777
pos += read;
8878
rem -= read;
8979
}
9080
return data;
9181
} catch (IOException e) {
92-
throw new RuntimeException("Failed to read zip entry " + resource, e);
82+
throw new RuntimeException("Failed to read zip entry " + res, e);
9383
}
94-
} finally {
95-
readLock.unlock();
9684
}
9785
}
9886

9987
@Override
10088
public URL getResourceURL(String resource) {
101-
final JarFile jarFile = readLockAcquireAndGetJarReference();
102-
try {
103-
JarEntry entry = jarFile.getJarEntry(resource);
89+
return JarFileReference.withJarFile(this, resource, JarResourceURLProvider.INSTANCE);
90+
}
91+
92+
private static class JarResourceURLProvider implements JarFileReference.JarFileConsumer<URL> {
93+
private static final JarResourceURLProvider INSTANCE = new JarResourceURLProvider();
94+
95+
@Override
96+
public URL apply(JarFile jarFile, Path path, String res) {
97+
JarEntry entry = jarFile.getJarEntry(res);
10498
if (entry == null) {
10599
return null;
106100
}
@@ -110,15 +104,7 @@ public URL getResourceURL(String resource) {
110104
if (realName.endsWith("/")) {
111105
realName = realName.substring(0, realName.length() - 1);
112106
}
113-
final URI jarUri = jarPath.toUri();
114-
// first create a URI which includes both the jar file path and the relative resource name
115-
// and then invoke a toURL on it. The URI reconstruction allows for any encoding to be done
116-
// for the "path" which includes the "realName"
117-
var ssp = new StringBuilder(jarUri.getPath().length() + realName.length() + 2);
118-
ssp.append(jarUri.getPath());
119-
ssp.append("!/");
120-
ssp.append(realName);
121-
final URL resUrl = new URI(jarUri.getScheme(), ssp.toString(), null).toURL();
107+
final URL resUrl = getUrl(path, realName);
122108
// wrap it up into a "jar" protocol URL
123109
//horrible hack to deal with '?' characters in the URL
124110
//seems to be the only way, the URI constructor just does not let you handle them in a sane way
@@ -136,8 +122,18 @@ public URL getResourceURL(String resource) {
136122
} catch (MalformedURLException | URISyntaxException e) {
137123
throw new RuntimeException(e);
138124
}
139-
} finally {
140-
readLock.unlock();
125+
}
126+
127+
private static URL getUrl(Path jarPath, String realName) throws MalformedURLException, URISyntaxException {
128+
final URI jarUri = jarPath.toUri();
129+
// first create a URI which includes both the jar file path and the relative resource name
130+
// and then invoke a toURL on it. The URI reconstruction allows for any encoding to be done
131+
// for the "path" which includes the "realName"
132+
var ssp = new StringBuilder(jarUri.getPath().length() + realName.length() + 2);
133+
ssp.append(jarUri.getPath());
134+
ssp.append("!/");
135+
ssp.append(realName);
136+
return new URI(jarUri.getScheme(), ssp.toString(), null).toURL();
141137
}
142138
}
143139

@@ -151,60 +147,15 @@ public ProtectionDomain getProtectionDomain() {
151147
return protectionDomain;
152148
}
153149

154-
private JarFile readLockAcquireAndGetJarReference() {
155-
while (true) {
156-
readLock.lock();
157-
final JarFile zipFileLocal = this.zipFile;
158-
if (zipFileLocal != null) {
159-
//Expected fast path: returns a reference to the open JarFile while owning the readLock
160-
return zipFileLocal;
161-
} else {
162-
//This Lock implementation doesn't allow upgrading a readLock to a writeLock, so release it
163-
//as we're going to need the WriteLock.
164-
readLock.unlock();
165-
//trigger the JarFile being (re)opened.
166-
ensureJarFileIsOpen();
167-
//Now since we no longer own any lock, we need to try again to obtain the readLock
168-
//and check for the reference still being valid.
169-
//This exposes us to a race with closing the just-opened JarFile;
170-
//however this should be extremely rare, so we can trust we won't loop much;
171-
//A counter doesn't seem necessary, as in fact we know that methods close()
172-
//and resetInternalCaches() are invoked each at most once, which limits the amount
173-
//of loops here in practice.
174-
}
175-
}
176-
}
177-
178-
private void ensureJarFileIsOpen() {
179-
writeLock.lock();
180-
try {
181-
if (this.zipFile == null) {
182-
try {
183-
this.zipFile = JarFiles.create(jarPath.toFile());
184-
} catch (IOException e) {
185-
throw new RuntimeException("Failed to open " + jarPath, e);
186-
}
187-
}
188-
} finally {
189-
writeLock.unlock();
190-
}
191-
}
192-
193150
@Override
194151
public void close() {
195-
writeLock.lock();
196-
try {
197-
final JarFile zipFileLocal = this.zipFile;
198-
if (zipFileLocal != null) {
199-
try {
200-
this.zipFile = null;
201-
zipFileLocal.close();
202-
} catch (Throwable e) {
203-
//ignore
204-
}
205-
}
206-
} finally {
207-
writeLock.unlock();
152+
var futureRef = jarFileReference.get();
153+
if (futureRef != null) {
154+
// The jarfile has been already used and it's going to be removed from the cache,
155+
// so the future must be already completed
156+
var ref = futureRef.getNow(null);
157+
assert (ref != null);
158+
ref.close(this);
208159
}
209160
}
210161

0 commit comments

Comments
 (0)