A cursor belongs to a {@link Txn}. + * + *
If {@link Txn} is a read-write transaction, LMDB will automatically close the cursor handle + * when the {@link Txn} is committed or aborted, meaning that Cursor#close() does not need to be + * called, however, if it is called, it must be called before the {@link Txn} is committed/aborted. + * + *
NOTE: If {@link Env.Builder#setSafeClose()} is set, the {@link Env} requires that all cursors + * are closed before the {@link Env} is closed, therefore it is good practice to explicitly call + * {@link Cursor#close()} or use a try-with-resources block on all types of cursor. + * + *
If {@link Txn} is a read-only transaction, {@link Cursor#close()} must be called to free up
+ * the cursor handle. This can be called at any time. Read-only transactions can 'moved' to a
+ * different transaction using the {@link Cursor#renew(Txn)} method. This can also be done at any
+ * time.
*
* @param The cursor handle will be freed and must not be used again after this call. Its transaction
- * must still be live if it is a write-transaction.
+ * must still be live (i.e. not committed or aborted) if it is a write-transaction.
*/
@Override
public void close() {
- if (closed) {
- return;
- }
- kv.close();
- if (SHOULD_CHECK) {
- env.checkNotClosed();
- if (!txn.isReadOnly()) {
- txn.checkReady();
+ if (closed.compareAndSet(false, true)) {
+ kv.close();
+ if (SHOULD_CHECK) {
+ env.checkNotClosed();
+ if (!txn.isReadOnly()) {
+ // TODO Rather than throwing if the txn is not in the right state to close
+ // we could check the txn state and only call mdb_cursor_close if the state is
+ // appropriate,
+ // i.e. (txn.isReadOnly() || txn.isReady())
+ // This would make using try-with-resources less likely to fail
+
+ // Cannot close the mdb_cursor if the txn is writable and not in a ready state
+ txn.checkReady();
+ }
}
+ LIB.mdb_cursor_close(ptrCursor);
+ releaseRefCount();
}
- LIB.mdb_cursor_close(ptrCursor);
- closed = true;
}
/**
- * Return count of duplicates for current key.
+ * Return count of duplicates for the current key.
*
* This call is only valid on databases that support sorted duplicate data items {@link
* DbiFlags#MDB_DUPSORT}.
*
- * @return count of duplicates for current key
+ * @return count of duplicates for the current key
*/
public long count() {
if (SHOULD_CHECK) {
@@ -368,7 +407,6 @@ public void putMultiple(final T key, final T val, final int elements) {
*/
public void putMultiple(final T key, final T val, final int elements, final PutFlagSet flags) {
if (SHOULD_CHECK) {
- requireNonNull(txn);
requireNonNull(key);
requireNonNull(val);
env.checkNotClosed();
@@ -397,19 +435,20 @@ public void putMultiple(final T key, final T val, final int elements, final PutF
* may be associated with a new read-only transaction, and referencing the same database handle as
* it was created with. This may be done whether the previous transaction is live or dead.
*
- * @param newTxn transaction handle
+ * @param newTxn The new transaction handle to associate with this cursor. It must be a read-only
+ * transaction and in a ready state, i.e. not committed/aborted/closed.
*/
public void renew(final Txn An instance will create and close its own cursor.
*
+ * Not thread safe.
+ *
* @param NOTE: If this is called while this thread already has an open write transaction, it will
+ * block indefinitely.
+ *
* @param key key to store in the database (not null)
* @param val value to store in the database (not null)
* @see #put(Txn, Object, Object, PutFlagSet)
diff --git a/src/main/java/org/lmdbjava/Env.java b/src/main/java/org/lmdbjava/Env.java
index 4bc6cca8..29196cce 100644
--- a/src/main/java/org/lmdbjava/Env.java
+++ b/src/main/java/org/lmdbjava/Env.java
@@ -1,5 +1,5 @@
/*
- * Copyright © 2016-2025 The LmdbJava Open Source Project
+ * Copyright © 2016-2026 The LmdbJava Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -45,7 +45,26 @@
import org.lmdbjava.Library.MDB_stat;
/**
- * LMDB environment.
+ * An LMDB environment that includes one or more databases ({@link Dbi}s). The {@link Env} manages
+ * the transactions and databases. An {@link Env} can only have one concurrent write transaction but
+ * supports multiple concurrent read transactions.
+ *
+ * WARNING: LMDBJava's and LMDB's performance comes from their low-level memory
+ * access, but this requires that you strictly adhere to the various contracts set out when using
+ * environments, databases, transactions, and cursors. Incorrect use of LMDBJava can lead to
+ * segmentation faults that can crash your application.
+ *
+ * By default, LMDBJava performs some checks, for example, checking that the {@link Env} is not
+ * closed when opening a transaction. It is possible, however, for race conditions to occur if you
+ * close the {@link Env} after one of these checks has been performed and before the transaction is
+ * opened. Note, these checks can be disabled by setting the {@link #DISABLE_CHECKS_PROP} system
+ * property to {@code true}. This may be beneficial in performance-critical applications.
+ *
+ * {@link Builder#setSafeClose()} can also be used to add additional checks that ensure the
+ * {@link Env} is not closed while transactions/cursors are in use.
+ *
+ * It is the responsibility of the user to ensure that the {@link Env} is not closed while
+ * transactions or cursors are in use.
*
* @param Will silently return if already closed or never opened.
+ * Will silently return if already closed.
+ *
+ * Before and during this call, the caller MUST ensure that:
+ *
+ * Violating this contract is undefined behaviour that can crash the whole JVM
+ * ({@code SIGSEGV} on Linux/macOS, {@code EXCEPTION_ACCESS_VIOLATION 0xC0000005} on Windows); it
+ * does not raise a Java exception. The underlying {@code mdb_env_close} unmaps the
+ * memory map, so a transaction still being started or used on another thread then dereferences
+ * freed memory — typically observed as a native crash in {@code mdb_txn_renew0} / {@code
+ * mdb_txn_begin}.
+ *
+ * If you must close an environment while reader threads may still be active, serialise the
+ * close against those readers in application code: e.g. a read/write lock where each reader holds
+ * the read lock for the entire duration of its transaction and {@code close()} holds the write
+ * lock, so the map is never unmapped while a read is in flight.
+ *
+ * If safeClose has been enabled on the {@link Env}, then this method will throw a {@link
+ * EnvInUseException} if transactions or RW cursors are still active.
+ *
+ * If safeClose has not been enabled then this method will perform the close regardless of
+ * whether it is in use or not with the implications detailed above.
+ *
+ * @throws EnvInUseException If safeClose has been set and a {@link Txn} or {@link Cursor} is
+ * still open on this {@link Env}
*/
@Override
public void close() {
- if (closed) {
- return;
- }
- closed = true;
+ refCounter.close(this::doClose);
+ }
+
+ /**
+ * Try to close the handle.
+ *
+ * Will silently return if already closed.
+ *
+ * Before and during this call, the caller MUST ensure that:
+ *
+ * Violating this contract is undefined behaviour that can crash the whole JVM
+ * ({@code SIGSEGV} on Linux/macOS, {@code EXCEPTION_ACCESS_VIOLATION 0xC0000005} on Windows); it
+ * does not raise a Java exception. The underlying {@code mdb_env_close} unmaps the
+ * memory map, so a transaction still being started or used on another thread then dereferences
+ * freed memory — typically observed as a native crash in {@code mdb_txn_renew0} / {@code
+ * mdb_txn_begin}.
+ *
+ * If you must close an environment while reader threads may still be active, serialise the
+ * close against those readers in application code: e.g. a read/write lock where each reader holds
+ * the read lock for the entire duration of its transaction and {@code close()} holds the write
+ * lock, so the map is never unmapped while a read is in flight.
+ *
+ * If safeClose has been enabled on the {@link Env}, then this method will return false if
+ * transactions or RW cursors are still active.
+ *
+ * If safeClose has not been enabled then this method will perform the close regardless of
+ * whether it is in use or not with the implications detailed above, i.e. it has the same
+ * behaviour as {@link #close()} with safeClose disabled.
+ *
+ * @return {@code true} if the environment was closed or {@code false} if it was already closed or
+ * safeClose prevented its closure due to being in use.
+ */
+ public boolean tryClose() {
+ return refCounter.tryClose(this::doClose);
+ }
+
+ private void doClose() {
LIB.mdb_env_close(ptr);
}
@@ -250,6 +371,7 @@ public List This method must not be called from concurrent threads.
*
+ * @param charset the charset to use when converting byte arrays to strings
* @return a list of DBI names (never null)
*/
public List The flag {@link DbiFlags#MDB_CREATE} needs to be set on the builder if you need to create a
- * new database before opening it.
+ * @return True if single-threaded
+ */
+ public boolean isSingleThreaded() {
+ return isSingleThreaded;
+ }
+
+ boolean isSafeClose() {
+ return safeClose;
+ }
+
+ /**
+ * Returns a builder for creating and opening a {@link Dbi} instance in this {@link Env}. This
+ * method is used for both opening an existing database or creating a new one.
+ *
+ * The flag {@link DbiFlags#MDB_CREATE} needs to be set on the builder if the database does not
+ * already exist, and you need to create it before opening it.
*
* @return A new builder instance for creating/opening a {@link Dbi}.
*/
@@ -499,9 +634,7 @@ public Dbi Obtain a transaction with the requested parent and flags.
+ * Must not race a concurrent {@link #close()} on another thread: the closed-check and the
+ * native transaction start are not atomic, so a close occurring between them can crash the
+ * JVM (see {@link #close()}).
*/
@Deprecated
public Txn Must not race a concurrent {@link #close()} on another thread: the closed-check and the
+ * native transaction start are not atomic, so a close occurring between them can crash the JVM
+ * (see {@link #close()}).
+ *
+ * When using a parent transaction, any committed changes will only be visible to the parent
+ * transaction and will only be fully committed to the {@link Dbi} if the root transaction is
+ * committed. Aborting this transaction will not roll back changes already made by the parent
+ * transaction.
+ *
+ * @param parent parent transaction (maybe null if no parent)
* @return a transaction (never null)
*/
public Txn If you want a read-write transaction, you can instead call {@link #txn(Txn)}. To obtain a
+ * read-only transaction, ensure {@link TxnFlags#MDB_RDONLY_TXN} is present in the {@link
+ * TxnFlagSet}.
+ *
+ * Must not race a concurrent {@link #close()} on another thread: the closed-check and the
+ * native transaction start are not atomic, so a close occurring between them can crash the JVM
+ * (see {@link #close()}).
+ *
+ * When using a parent transaction, any committed changes will only be visible to the parent
+ * transaction and will only be fully committed to the {@link Dbi} if the root transaction is
+ * committed. Aborting this transaction will not roll back changes already made by the parent
+ * transaction.
+ *
+ * @param parent parent transaction (maybe null if no parent)
* @param flags applicable flags (e.g. for a reusable, read-only transaction). If the set of flags
- * is used frequently it is recommended to hold a static instance of the {@link TxnFlagSet}
+ * is used frequently, it is recommended to hold a static instance of the {@link TxnFlagSet}
* for re-use.
* @return a transaction (never null)
+ * @throws Env.AlreadyClosedException if this environment has already been closed.
*/
public Txn Must not race a concurrent {@link #close()} on another thread: the closed-check and the
+ * native transaction start are not atomic, so a close occurring between them can crash the JVM
+ * (see {@link #close()}).
+ *
* @return a read-only transaction
+ * @throws Env.AlreadyClosedException if this environment has already been closed.
*/
public Txn Must not race a concurrent {@link #close()} on another thread: the closed-check and the
+ * native transaction start are not atomic, so a close occurring between them can crash the JVM
+ * (see {@link #close()}).
+ *
* @return a read-write transaction
+ * @throws Env.AlreadyClosedException if this environment has already been closed
*/
public Txn When enabled, the environment tracks its live transactions and read-write cursors so that
+ * closure of the {@link Env} is prevented if transactions or cursors are active. This adds a
+ * small amount of bookkeeping on transaction start/close; it is disabled by
+ * default so applications that already manage their own threading (the common
+ * low-latency case) pay nothing. When enabled, {@link Env#close()} will throw a {@link
+ * EnvInUseException} if transactions or cursors are active.
+ *
+ * @return the builder
+ */
+ public Builder When enabled, the environment tracks its live transactions and read-write cursors so that
+ * closure of the {@link Env} is prevented if transactions or cursors are active. This adds a
+ * small amount of bookkeeping on transaction start/close; it is disabled by
+ * default so applications that already manage their own threading (the common
+ * low-latency case) pay nothing. When enabled, {@link Env#close()} will throw a {@link
+ * EnvInUseException} if transactions or cursors are active.
+ *
+ * @param safeClose true to enable cursor/transaction tracking.
+ * @return the builder
+ */
+ public Builder If runnable is null, this is a no-op.
+ */
+ default void use(final Runnable runnable) {
+ if (runnable != null) {
+ final RefCounterReleaser releaser = acquire();
+ try {
+ runnable.run();
+ } finally {
+ releaser.release();
+ }
+ }
+ }
+
+ /**
+ * If the reference count is zero, onClose will be called. This {@link RefCounter} will be marked
+ * as closed so all future calls to acquire will throw a {@link
+ * org.lmdbjava.Env.AlreadyClosedException}. If the count is non-zero, {@link
+ * org.lmdbjava.Env.EnvInUseException} will be thrown. If already closed, this is a no-op.
+ *
+ * @throws org.lmdbjava.Env.EnvInUseException If the {@link Env} has open transactions/cursors.
+ */
+ void close(final Runnable onClose);
+
+ /**
+ * If the reference count is zero, onClose will be called and true returned. This {@link
+ * RefCounter} will be marked as closed so all future calls to acquire will throw a {@link
+ * org.lmdbjava.Env.AlreadyClosedException}. If the count is non-zero it is a no-op and false is
+ * returned. If already closed, this is a no-op and false is returned.
+ *
+ * @return True if onClose was called.
+ */
+ boolean tryClose(final Runnable onClose);
+
+ /**
+ * @return True if {@link RefCounter} has been closed.
+ */
+ boolean isClosed();
+
+ /** If it is in a CLOSED state, throw a {@link org.lmdbjava.Env.AlreadyClosedException} */
+ default void checkNotClosed() {
+ if (isClosed()) {
+ throw new Env.AlreadyClosedException();
+ }
+ }
+
+ /**
+ * @return The current count of items in use. It will return 0 if already closed.
+ */
+ long getCount();
+
+ @FunctionalInterface
+ interface RefCounterReleaser {
+
+ /**
+ * Call this after using the {@link RefCounter} controlled object. Subsequent calls to this
+ * method are a no-op.
+ */
+ void release();
+ }
+}
diff --git a/src/main/java/org/lmdbjava/SimpleRefCounter.java b/src/main/java/org/lmdbjava/SimpleRefCounter.java
new file mode 100644
index 00000000..98c9be0f
--- /dev/null
+++ b/src/main/java/org/lmdbjava/SimpleRefCounter.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright © 2016-2026 The LmdbJava Open Source Project
+ *
+ * Licensed 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.lmdbjava;
+
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * An implementation of {@link RefCounter} that uses an {@link AtomicInteger} to track the number of
+ * references to a resource.
+ */
+class SimpleRefCounter implements RefCounter {
+ private static final int CLOSED_VALUE = Integer.MIN_VALUE;
+ private final AtomicInteger counter = new AtomicInteger(0);
+
+ @Override
+ public boolean isClosed() {
+ return counter.get() == CLOSED_VALUE;
+ }
+
+ @Override
+ public RefCounterReleaser acquire() {
+ final int newVal =
+ counter.updateAndGet(currVal -> currVal == CLOSED_VALUE ? currVal : currVal + 1);
+ if (newVal == CLOSED_VALUE) {
+ throw new Env.AlreadyClosedException();
+ }
+
+ final AtomicBoolean hasReleased = new AtomicBoolean(false);
+ return () -> {
+ // Prevent duplicate release calls
+ if (hasReleased.compareAndSet(false, true)) {
+ release();
+ }
+ };
+ }
+
+ @Override
+ public void close(final Runnable onClose) {
+ Objects.requireNonNull(onClose);
+ if (counter.get() != CLOSED_VALUE) {
+ // Set to CLOSED_VALUE to indicate closure, if the count is 0
+ if (counter.compareAndSet(0, CLOSED_VALUE)) {
+ onClose.run();
+ } else {
+ throw new Env.EnvInUseException(getCount());
+ }
+ }
+ }
+
+ @Override
+ public boolean tryClose(Runnable onClose) {
+ Objects.requireNonNull(onClose);
+ if (counter.get() != CLOSED_VALUE) {
+ // Set to CLOSED_VALUE to indicate closure, if the count is 0
+ if (counter.compareAndSet(0, CLOSED_VALUE)) {
+ onClose.run();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void release() {
+ final int newVal =
+ counter.updateAndGet(currVal -> currVal == CLOSED_VALUE ? currVal : currVal - 1);
+ if (newVal == CLOSED_VALUE) {
+ throw new Env.AlreadyClosedException();
+ }
+ }
+
+ @Override
+ public long getCount() {
+ return Math.max(0, counter.get());
+ }
+}
diff --git a/src/main/java/org/lmdbjava/SingleThreadedRefCounter.java b/src/main/java/org/lmdbjava/SingleThreadedRefCounter.java
new file mode 100644
index 00000000..4e293f3f
--- /dev/null
+++ b/src/main/java/org/lmdbjava/SingleThreadedRefCounter.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright © 2016-2026 The LmdbJava Open Source Project
+ *
+ * Licensed 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.lmdbjava;
+
+import java.util.Objects;
+
+/** A {@link RefCounter} intented for use only in single-threaded environments. */
+public class SingleThreadedRefCounter implements RefCounter {
+
+ private int refCount;
+ private boolean isClosed = false;
+
+ public SingleThreadedRefCounter() {}
+
+ @Override
+ public RefCounterReleaser acquire() {
+ if (isClosed) {
+ throw new Env.AlreadyClosedException();
+ }
+ refCount++;
+ return new SingleThreadedReleaser(this);
+ }
+
+ private void release() {
+ if (refCount == 0) {
+ throw new IllegalStateException("Attempt to release with a refCount of zero");
+ }
+ refCount--;
+ }
+
+ @Override
+ public void use(Runnable runnable) {
+ if (runnable != null) {
+ final RefCounterReleaser releaser = acquire();
+ try {
+ runnable.run();
+ } finally {
+ releaser.release();
+ }
+ }
+ }
+
+ @Override
+ public void close(final Runnable onClose) {
+ Objects.requireNonNull(onClose);
+ if (!isClosed) {
+ final long count = getCount();
+ if (count == 0) {
+ isClosed = true;
+ onClose.run();
+ } else {
+ throw new Env.EnvInUseException(count);
+ }
+ }
+ }
+
+ @Override
+ public boolean tryClose(Runnable onClose) {
+ Objects.requireNonNull(onClose);
+ if (!isClosed) {
+ final long count = getCount();
+ if (count == 0) {
+ isClosed = true;
+ onClose.run();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean isClosed() {
+ return isClosed;
+ }
+
+ @Override
+ public long getCount() {
+ return refCount;
+ }
+
+ private static class SingleThreadedReleaser implements RefCounterReleaser {
+
+ private final SingleThreadedRefCounter singleThreadedRefCounter;
+ private boolean released = false;
+
+ private SingleThreadedReleaser(final SingleThreadedRefCounter singleThreadedRefCounter) {
+ this.singleThreadedRefCounter = singleThreadedRefCounter;
+ }
+
+ @Override
+ public void release() {
+ if (!released) {
+ released = true;
+ singleThreadedRefCounter.release();
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/lmdbjava/StripedRefCounter.java b/src/main/java/org/lmdbjava/StripedRefCounter.java
new file mode 100644
index 00000000..a052f9e5
--- /dev/null
+++ b/src/main/java/org/lmdbjava/StripedRefCounter.java
@@ -0,0 +1,414 @@
+/*
+ * Copyright © 2016-2026 The LmdbJava Open Source Project
+ *
+ * Licensed 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.lmdbjava;
+
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * An implementation of {@link RefCounter} that uses an array of {@link AtomicInteger}s to track the
+ * number of references to a resource. Offers better concurrency performance than {@link
+ * SimpleRefCounter} which used a single {@link AtomicInteger}, at the cost of more memory due to
+ * the additional {@link AtomicInteger}s.
+ *
+ * Each thread will use the {@link AtomicInteger} at an array offset determined by a hash of the
+ * thread's id.
+ *
+ * The number of stripes configurable but immutable once set.
+ */
+class StripedRefCounter implements RefCounter {
+ private static final int PROCESSOR_COUNT = Runtime.getRuntime().availableProcessors();
+
+ /**
+ * Counter value used to indicate a count of zero while a sum of all counters is being performed.
+ */
+ private static final int MAGIC_ZERO_VALUE = Integer.MIN_VALUE;
+
+ /** Counter value used to indicate that this RefCounter has been closed. */
+ private static final int MAGIC_CLOSED_VALUE = Integer.MAX_VALUE;
+
+ /** The maximum possible count value on one stripe. */
+ private static final int MAX_COUNTER_VALUE = Integer.MAX_VALUE - 1;
+
+ private static final int DEFAULT_STRIPES = 64;
+
+ /** Maximum number of stripes. */
+ private static final int MAX_STRIPES = 256;
+
+ private final Stripe[] counters;
+ private final AtomicBoolean isClosed = new AtomicBoolean(false);
+
+ /**
+ * Bit mask for fast stripe index calculation. Equal to (stripeCount - 1). Used with bitwise AND
+ * for O(1) hashing with no modulo operation.
+ */
+ private final int stripeMask;
+
+ StripedRefCounter() {
+ this(getDefaultStripeCount());
+ }
+
+ StripedRefCounter(final int stripeCount) {
+ final int effectiveStripeCount = lowestPowerOfTwoGreaterThanOrEqualTo(stripeCount);
+ validateStripeCount(effectiveStripeCount);
+ this.stripeMask = effectiveStripeCount - 1;
+ this.counters = new Stripe[effectiveStripeCount];
+ for (int i = 0; i < effectiveStripeCount; i++) {
+ counters[i] = new Stripe(this);
+ }
+ }
+
+ public int getStripeCount() {
+ return counters.length;
+ }
+
+ @Override
+ public boolean isClosed() {
+ return isClosed.get();
+ }
+
+ private AtomicInteger getCounterForThisThread() {
+ return counters[getStripeIdx()].counter;
+ }
+
+ private Stripe getStripeForThisThread() {
+ return counters[getStripeIdx()];
+ }
+
+ @Override
+ public RefCounterReleaser acquire() {
+ final Stripe stripe = getStripeForThisThread();
+ final AtomicInteger counter = stripe.counter;
+ if (!addToCounter(counter, Delta.PLUS_ONE)) {
+ // Counting is in progress, so we need to get a lock which will likely block
+ // until the count is complete
+ synchronized (this) {
+ if (!addToCounter(counter, Delta.PLUS_ONE)) {
+ throw new IllegalStateException("Count should not be in progress while we hold the lock");
+ }
+ }
+ }
+ return stripe.createReleaser();
+ }
+
+ private static int getDefaultStripeCount() {
+ return Math.min(
+ MAX_STRIPES,
+ Math.max(lowestPowerOfTwoGreaterThanOrEqualTo(PROCESSOR_COUNT * 2), DEFAULT_STRIPES));
+ }
+
+ /**
+ * Returns the lowest power of two that is greater than or equal to {@code value}.
+ *
+ * @param value input value, must be positive
+ * @return lowest power of two >= value
+ * @throws IllegalArgumentException if {@code value <= 0} or the result would overflow an int
+ */
+ static int lowestPowerOfTwoGreaterThanOrEqualTo(final int value) {
+ if (value <= 0) {
+ throw new IllegalArgumentException("Value must be positive, got: " + value);
+ }
+ if (value > (1 << 30)) {
+ throw new IllegalArgumentException(
+ "Value is too large to round up to a positive int power of two, got: " + value);
+ }
+ return value == 1 ? 1 : Integer.highestOneBit(value - 1) << 1;
+ }
+
+ private void release(final AtomicInteger counter) {
+ if (!addToCounter(counter, Delta.MINUS_ONE)) {
+ synchronized (this) {
+ if (!addToCounter(counter, Delta.MINUS_ONE)) {
+ throw new IllegalStateException("Count should not be in progress while we hold the lock");
+ }
+ }
+ }
+ }
+
+ @Override
+ public void close(final Runnable onClose) {
+ final Long count = doClose(onClose);
+ if (count != null && count > 0) {
+ throw new Env.EnvInUseException(count);
+ }
+ }
+
+ @Override
+ public boolean tryClose(Runnable onClose) {
+ final Long count = doClose(onClose);
+ return count != null && count == 0;
+ }
+
+ /**
+ * @return A non-zero count to indicate the resource is in use. A zero count to indicate the
+ * onClose was called successfully. A null count to indicate the resource was already in a
+ * closed state.
+ */
+ private Long doClose(final Runnable onClose) {
+ Objects.requireNonNull(onClose);
+
+ // close is idempotent so silently drop out
+ if (isClosed.get()) {
+ return null;
+ }
+
+ synchronized (this) {
+ if (isClosed.get()) {
+ return null;
+ }
+
+ // Once we have marked all counters as count-in-progress, any threads trying to mutate the
+ // counters
+ // will fail, then re-attempt under lock, so will have to wait for us to complete the count.
+ // Marking all the counters is a non-atomic operation, so another thread may increment a
+ // counter
+ // while we are in the middle of marking them, however, once all are marked, threads will be
+ // blocked
+ // from decrementing until we have called markCountersAsNoCountInProgress(), thus we will get
+ // a non-zero
+ // count and throw an EnvInUseException.
+
+ markCountersAsCountInProgress(); // 0=>MAGIC_ZERO_VALUE else i=>i*-1
+
+ // At this point, no other thread can mutate the counters, so we are safe to use a sum of all
+ // the counters.
+ try {
+ final long totalCount = sumCounters();
+ if (totalCount == 0) {
+ // No permits on loan so safe to close.
+ onClose.run();
+ // Only mark as closed if the runnable succeeds.
+ isClosed.set(true);
+ // Mark all counters as closed to prevent any future acquire() calls
+ for (final Stripe stripe : counters) {
+ stripe.counter.set(MAGIC_CLOSED_VALUE);
+ }
+ }
+ return totalCount;
+ } finally {
+ if (!isClosed.get()) {
+ // Return all counters to their original positive values so
+ // acquire/release can resume as normal
+ markCountersAsNoCountInProgress(); // MAGIC_ZERO_VALUE=>0 else i=>i*-1
+ }
+ }
+ }
+ }
+
+ /**
+ * MUST be called after {@link StripedRefCounter#markCountersAsCountInProgress()} has been called
+ * and under lock. Once complete, {@link StripedRefCounter#markCountersAsNoCountInProgress()} must
+ * be called.
+ */
+ private long sumCounters() {
+ long totalCount = 0;
+ for (Stripe stripe : counters) {
+ int count = stripe.counter.get();
+ if (count == MAGIC_CLOSED_VALUE) { // Integer.MAX_VALUE
+ throw new Env.AlreadyClosedException();
+ } else if (count != MAGIC_ZERO_VALUE) { // Integer.MIN_VALUE
+ // count should be negative at this point
+ if (count > 0) {
+ throw new IllegalStateException("Count should be negative at this point, got: " + count);
+ }
+ totalCount += count;
+ }
+ }
+ // The individual counts were all negative, so use the abs value
+ totalCount = Math.abs(totalCount);
+ return totalCount;
+ }
+
+ @Override
+ public long getCount() {
+ if (isClosed()) {
+ return 0;
+ }
+ synchronized (this) {
+ if (isClosed()) {
+ return 0;
+ }
+ // This will stop any other thread from incrementing/decrementing the counter
+ markCountersAsCountInProgress();
+ try {
+ return sumCounters();
+ } finally {
+ markCountersAsNoCountInProgress();
+ }
+ }
+ }
+
+ /**
+ * @return False if a count is in progress, else true
+ * @throws Env.AlreadyClosedException If this {@link RefCounter} has already been successfully
+ * closed.
+ */
+ private boolean addToCounter(final AtomicInteger counter, final Delta delta) {
+ // Use a while loop with get() and compareAndSet(), rather than throwing exceptions inside
+ // updateAndGet().
+ while (true) {
+ final int currVal = counter.get();
+
+ if (currVal == MAGIC_CLOSED_VALUE) {
+ // Once MAGIC_CLOSED_VALUE is set, it is never mutated again.
+ throw new Env.AlreadyClosedException();
+ } else if (currVal < 0) {
+ // A count is in progress, so we can drop out and try again under lock
+ return false;
+ } else if (currVal == MAX_COUNTER_VALUE && delta == Delta.PLUS_ONE) {
+ // This implies we have a LOT of txns/cursors open, should never happen
+ throw new IllegalStateException("Reference count overflow");
+ } else if (currVal == 0 && delta == Delta.MINUS_ONE) {
+ throw new IllegalStateException("Reference count underflow");
+ }
+
+ final int newVal = currVal + delta.deltaValue;
+ if (counter.compareAndSet(currVal, newVal)) {
+ return true;
+ }
+ }
+ }
+
+ /** Must be called while holding the lock on this object. */
+ private void markCountersAsNoCountInProgress() {
+ for (Stripe stripe : counters) {
+ // Multiply value by -1 so we can indicate to other threads that a count is in progress
+ // while maintaining the count. Have to use a special replacement value for zero.
+ stripe.counter.updateAndGet(
+ currVal -> {
+ if (currVal == MAGIC_ZERO_VALUE) {
+ return 0;
+ } else if (currVal == MAGIC_CLOSED_VALUE) {
+ // If this method is used correctly under lock, we should never see this value, but
+ // preserve the
+ // closed state just in case
+ return MAGIC_CLOSED_VALUE;
+ } else {
+ return Math.abs(currVal);
+ }
+ });
+ }
+ }
+
+ /** Must be called while holding the lock on this object. */
+ private void markCountersAsCountInProgress() {
+ // It is possible that another thread will call acquire() while we are mid-loop.
+ // If that thread uses a counter that has not yet been marked as count-in-progress, they will
+ // succeed with incrementing the counter.
+ // We will then get a sum that includes the increment from their acquire() call.
+ // They will be blocked from calling release() until markCountersAsNoCountInProgress() has
+ // been called by us.
+ for (final Stripe stripe : counters) {
+ stripe.counter.updateAndGet(
+ currVal -> {
+ if (currVal == 0) {
+ // Use a magic value to mark this zero-value counter as having a count in progress
+ return MAGIC_ZERO_VALUE;
+ } else if (currVal == MAGIC_CLOSED_VALUE) {
+ // If this method is used correctly under lock, we should never see this value, but
+ // preserve the
+ // closed state just in case
+ return MAGIC_CLOSED_VALUE;
+ } else {
+ // Make the value negative to indicate a count in progress
+ return Math.abs(currVal) * -1;
+ }
+ });
+ }
+ }
+
+ private void validateStripeCount(final int stripeCount) {
+ if (stripeCount <= 0) {
+ throw new IllegalArgumentException("Stripe count must be positive, got: " + stripeCount);
+ }
+ if (stripeCount > MAX_STRIPES) {
+ throw new IllegalArgumentException(
+ "Stripe count exceeds maximum. Got: " + stripeCount + ", max: " + MAX_STRIPES);
+ }
+ }
+
+ /**
+ * Computes the stripe index for the current thread using Stafford variant 13 mixing.
+ *
+ * This method applies a high-quality 64-bit hash function (MurmurHash3 finalizer) to the
+ * thread ID before masking to the stripe count. This provides:
+ *
+ * The Stafford13 mixing function is used internally by {@link java.util.SplittableRandom} for
+ * seed initialization. See: Better Bit
+ * Mixing
+ *
+ * @return stripe index from 0 to stripeCount - 1 (inclusive)
+ */
+ private int getStripeIdx() {
+ // TODO In >= Java19, getId() is deprecated, so change to .threadId()
+ long threadId = Thread.currentThread().getId();
+ // Stafford13 for sequential inputs
+ threadId = (threadId ^ (threadId >>> 30)) * 0xbf58476d1ce4e5b9L;
+ threadId = (threadId ^ (threadId >>> 27)) * 0x94d049bb133111ebL;
+ return (int) ((threadId ^ (threadId >>> 31)) & stripeMask);
+ }
+
+ private enum CloseOutcome {
+ /** Successfully closed. */
+ CLOSED,
+ /** The resource is in use. */
+ IN_USE,
+ /** Already in a closed state. */
+ ALREADY_CLOSED,
+ ;
+ }
+
+ private enum Delta {
+ PLUS_ONE(1),
+ MINUS_ONE(-1),
+ ;
+
+ private final int deltaValue;
+
+ Delta(int deltaValue) {
+ this.deltaValue = deltaValue;
+ }
+ }
+
+ private static class Stripe {
+ private final StripedRefCounter stripedRefCounter;
+ private final AtomicInteger counter;
+
+ Stripe(StripedRefCounter stripedRefCounter) {
+ this.stripedRefCounter = stripedRefCounter;
+ this.counter = new AtomicInteger();
+ }
+
+ RefCounterReleaser createReleaser() {
+ final AtomicBoolean hasReleased = new AtomicBoolean(false);
+ return () -> {
+ // Prevent duplicate release calls
+ if (hasReleased.compareAndSet(false, true)) {
+ stripedRefCounter.release(counter);
+ }
+ };
+ }
+ }
+}
diff --git a/src/main/java/org/lmdbjava/SynchronisedRefCounter.java b/src/main/java/org/lmdbjava/SynchronisedRefCounter.java
new file mode 100644
index 00000000..85158606
--- /dev/null
+++ b/src/main/java/org/lmdbjava/SynchronisedRefCounter.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright © 2016-2026 The LmdbJava Open Source Project
+ *
+ * Licensed 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.lmdbjava;
+
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * An implementation of {@link RefCounter} that uses synchronisation to track the number of
+ * references to a resource.
+ */
+class SynchronisedRefCounter implements RefCounter {
+ private boolean isClosed = false;
+ private int counter = 0;
+
+ @Override
+ public boolean isClosed() {
+ synchronized (this) {
+ return isClosed;
+ }
+ }
+
+ @Override
+ public RefCounterReleaser acquire() {
+ synchronized (this) {
+ if (isClosed) {
+ throw new Env.AlreadyClosedException();
+ }
+ counter++;
+ }
+ final AtomicBoolean hasReleased = new AtomicBoolean(false);
+ return () -> {
+ // Prevent duplicate release calls
+ if (hasReleased.compareAndSet(false, true)) {
+ release();
+ }
+ };
+ }
+
+ @Override
+ public void close(final Runnable onClose) {
+ Objects.requireNonNull(onClose);
+ synchronized (this) {
+ if (!isClosed) {
+ if (counter != 0) {
+ throw new Env.EnvInUseException(getCount());
+ } else {
+ isClosed = true;
+ onClose.run();
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean tryClose(Runnable onClose) {
+ Objects.requireNonNull(onClose);
+ synchronized (this) {
+ if (!isClosed) {
+ if (counter == 0) {
+ isClosed = true;
+ onClose.run();
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private void release() {
+ synchronized (this) {
+ if (isClosed) {
+ throw new Env.AlreadyClosedException();
+ }
+ if (counter == 0) {
+ throw new IllegalStateException("Attempt to decrement counter below zero");
+ }
+ counter--;
+ }
+ }
+
+ @Override
+ public long getCount() {
+ synchronized (this) {
+ return counter;
+ }
+ }
+}
diff --git a/src/main/java/org/lmdbjava/TargetName.java b/src/main/java/org/lmdbjava/TargetName.java
index 49a65dea..7987c0c4 100644
--- a/src/main/java/org/lmdbjava/TargetName.java
+++ b/src/main/java/org/lmdbjava/TargetName.java
@@ -1,5 +1,5 @@
/*
- * Copyright © 2016-2025 The LmdbJava Open Source Project
+ * Copyright © 2016-2026 The LmdbJava Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
diff --git a/src/main/java/org/lmdbjava/Txn.java b/src/main/java/org/lmdbjava/Txn.java
index 7e9aacf9..df6259b2 100644
--- a/src/main/java/org/lmdbjava/Txn.java
+++ b/src/main/java/org/lmdbjava/Txn.java
@@ -1,5 +1,5 @@
/*
- * Copyright © 2016-2025 The LmdbJava Open Source Project
+ * Copyright © 2016-2026 The LmdbJava Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -31,7 +31,21 @@
import jnr.ffi.Pointer;
/**
- * LMDB transaction.
+ * An LMDB ACID transaction.
+ *
+ * A transaction belongs to an {@link Env} and must be closed before the {@link Env} is closed.
+ * Only one concurrent write transaction is supported. Attempts to open another write transaction
+ * will block until the open write transaction is closed.
+ *
+ * {@link Txn#commit()} must be called to commit any changes made within the transaction.
+ *
+ * Uncommitted changes can be rolled back by either calling {@link Txn#close()} or calling {@link
+ * Txn#abort()}.
+ *
+ * Closing a transaction without first calling {@link Txn#commit()} will perform an implicit
+ * rollback of any uncommitted changes made within the transaction.
+ *
+ * Transactions can be nested
*
* @param If this is a read-write transaction, and you have any open {@link Cursor}s against this
+ * transaction, they MUST be closed first, else you will not be able to close the
+ * cursor after this transaction has been committed.
+ */
public void abort() {
if (SHOULD_CHECK) {
env.checkNotClosed();
@@ -76,13 +105,24 @@ public void abort() {
checkReady();
state = DONE;
LIB.mdb_txn_abort(ptr);
+
+ // No call to refCounterReleaser.release() here because the keyVal is still open
+ // and the txn can still be reset.
}
/**
- * Closes this transaction by aborting if not already committed.
+ * Closes this transaction. Any uncommitted work will be aborted first.
+ *
+ * If any {@link Cursor}s have been opened on this transaction, they MUST be
+ * closed first, else you will not be able to close the cursor after its transaction has been
+ * closed.
*
* Closing the transaction will invoke {@link BufferProxy#deallocate(java.lang.Object)} for
* each read-only buffer (ie the key and value).
+ *
+ * If this is a read-write transaction, and you have any open {@link Cursor}s against this
+ * transaction, they MUST be closed first, else you will not be able to close the
+ * cursor after this transaction has been closed.
*/
@Override
public void close() {
@@ -97,9 +137,20 @@ public void close() {
}
keyVal.close();
state = RELEASED;
+
+ refCounterReleaser.release();
}
- /** Commits this transaction. */
+ /**
+ * Commits this transaction.
+ *
+ * If you have an open cursor using this transaction, you must close the cursor before
+ * committing.
+ *
+ * If this is a read-write transaction, and you have any open {@link Cursor}s against this
+ * transaction, they MUST be closed first, else you will not be able to close the
+ * cursor after this transaction has been committed.
+ */
public void commit() {
if (SHOULD_CHECK) {
env.checkNotClosed();
@@ -124,7 +175,7 @@ public long getId() {
/**
* Obtains this transaction's parent.
*
- * @return the parent transaction (may be null)
+ * @return the parent transaction (if present, i.e. may be null)
*/
public Txn Not applicable to write transactions.
*/
public void reset() {
if (SHOULD_CHECK) {
@@ -204,6 +266,10 @@ void checkReady() {
}
}
+ boolean isReady() {
+ return state == READY;
+ }
+
void checkWritesAllowed() {
if (readOnly) {
throw new ReadWriteRequiredException();
@@ -282,7 +348,10 @@ public static final class NotReadyException extends LmdbException {
/** Creates a new instance. */
public NotReadyException() {
- super("Transaction is not in ready state");
+ super(
+ "Transaction is not in ready state, i.e. it has been closed/committed/aborted/reset. "
+ + "You may see this if have you tried to close a cursor after committing the transaction, "
+ + "or if you have tried to use a cursor after closing its transaction.");
}
}
diff --git a/src/test/java/org/lmdbjava/AbstractFlagSetTest.java b/src/test/java/org/lmdbjava/AbstractFlagSetTest.java
index a886d9e1..217217e7 100644
--- a/src/test/java/org/lmdbjava/AbstractFlagSetTest.java
+++ b/src/test/java/org/lmdbjava/AbstractFlagSetTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright © 2016-2025 The LmdbJava Open Source Project
+ * Copyright © 2016-2026 The LmdbJava Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -67,6 +67,15 @@ void testSingleFlagSet() {
final List
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *