2121import java .io .Closeable ;
2222import java .io .File ;
2323import java .io .IOException ;
24+ import java .io .PrintStream ;
2425import java .net .MalformedURLException ;
2526import java .net .URL ;
2627import java .util .ArrayList ;
@@ -81,6 +82,8 @@ public class BoxStore implements Closeable {
8182
8283 /** The ObjectBox database version this Java library is known to work with. */
8384 private static final String VERSION = "4.3.1-2025-08-02" ;
85+
86+ private static final String OBJECTBOX_PACKAGE_NAME = "objectbox" ;
8487 private static BoxStore defaultStore ;
8588
8689 /** Currently used DB dirs with values from {@link #getCanonicalPath(File)}. */
@@ -238,6 +241,7 @@ public static boolean isSyncServerAvailable() {
238241
239242 native long nativePanicModeRemoveAllObjects (long store , int entityId );
240243
244+ private final PrintStream errorOutputStream ;
241245 private final File directory ;
242246 private final String canonicalPath ;
243247 /** Reference to the native store. Should probably get through {@link #getNativeStore()} instead. */
@@ -283,6 +287,7 @@ public static boolean isSyncServerAvailable() {
283287 relinker = builder .relinker ;
284288 NativeLibraryLoader .ensureLoaded ();
285289
290+ errorOutputStream = builder .errorOutputStream ;
286291 directory = builder .directory ;
287292 canonicalPath = getCanonicalPath (directory );
288293 verifyNotAlreadyOpen (canonicalPath );
@@ -613,7 +618,7 @@ public Transaction beginTx() {
613618 // Because write TXs are typically not cached, initialCommitCount is not as relevant than for read TXs.
614619 int initialCommitCount = commitCount ;
615620 if (debugTxWrite ) {
616- System . out .println ("Begin TX with commit count " + initialCommitCount );
621+ getOutput () .println ("Begin TX with commit count " + initialCommitCount );
617622 }
618623 long nativeTx = nativeBeginTx (getNativeStore ());
619624 if (nativeTx == 0 ) throw new DbException ("Could not create native transaction" );
@@ -638,7 +643,7 @@ public Transaction beginReadTx() {
638643 // TODO add multithreaded test for this
639644 int initialCommitCount = commitCount ;
640645 if (debugTxRead ) {
641- System . out .println ("Begin read TX with commit count " + initialCommitCount );
646+ getOutput () .println ("Begin read TX with commit count " + initialCommitCount );
642647 }
643648 long nativeTx = nativeBeginReadTx (getNativeStore ());
644649 if (nativeTx == 0 ) throw new DbException ("Could not create native read transaction" );
@@ -693,12 +698,17 @@ public void close() {
693698 // (due to all Java APIs doing closed checks).
694699 closed = true ;
695700
701+ // Stop accepting new tasks (async calls, query publishers) on the internal thread pool
702+ internalThreadPool ().shutdown ();
703+ // Give running tasks some time to finish, print warnings if they do not to help callers fix their code
704+ checkThreadTermination ();
705+
696706 List <Transaction > transactionsToClose ;
697707 synchronized (transactions ) {
698708 // Give open transactions some time to close (BoxStore.unregisterTransaction() calls notify),
699709 // 1000 ms should be long enough for most small operations and short enough to avoid ANRs on Android.
700710 if (hasActiveTransaction ()) {
701- System . out .println ("Briefly waiting for active transactions before closing the Store..." );
711+ getOutput () .println ("Briefly waiting for active transactions before closing the Store..." );
702712 try {
703713 // It is fine to hold a lock on BoxStore.this as well as BoxStore.unregisterTransaction()
704714 // only synchronizes on "transactions".
@@ -708,7 +718,7 @@ public void close() {
708718 // If interrupted, continue with releasing native resources
709719 }
710720 if (hasActiveTransaction ()) {
711- System . err .println ("Transactions are still active:"
721+ getErrorOutput () .println ("Transactions are still active:"
712722 + " ensure that all database operations are finished before closing the Store!" );
713723 }
714724 }
@@ -726,10 +736,6 @@ public void close() {
726736 if (handleToDelete != 0 ) { // failed before native handle was created?
727737 nativeDelete (handleToDelete );
728738 }
729-
730- // When running the full unit test suite, we had 100+ threads before, hope this helps:
731- threadPool .shutdown ();
732- checkThreadTermination ();
733739 }
734740 }
735741 if (!oldClosedState ) {
@@ -740,24 +746,52 @@ public void close() {
740746 }
741747 }
742748
743- /** dump thread stacks if pool does not terminate promptly. */
749+ /**
750+ * Waits briefly for the internal {@link #internalThreadPool()} to terminate. If it does not terminate in time,
751+ * prints stack traces of the pool threads.
752+ */
744753 private void checkThreadTermination () {
745754 try {
746- if (!threadPool .awaitTermination (1 , TimeUnit .SECONDS )) {
747- int activeCount = Thread .activeCount ();
748- System .err .println ("Thread pool not terminated in time; printing stack traces..." );
749- Thread [] threads = new Thread [activeCount + 2 ];
755+ if (!internalThreadPool ().awaitTermination (1 , TimeUnit .SECONDS )) {
756+ getErrorOutput ().println ("ObjectBox thread pool not terminated in time." +
757+ " Ensure all async calls have completed and subscriptions are cancelled before closing the Store." +
758+ "\n Dumping stack traces of threads on the pool and any using ObjectBox APIs:" +
759+ "\n === BEGIN OF DUMP ===" );
760+ // Note: this may not print any pool threads if other threads are started while enumerating
761+ // (and the pool threads do not make it into the threads array).
762+ Thread [] threads = new Thread [Thread .activeCount ()];
750763 int count = Thread .enumerate (threads );
751764 for (int i = 0 ; i < count ; i ++) {
752- System .err .println ("Thread: " + threads [i ].getName ());
753- Thread .dumpStack ();
765+ Thread thread = threads [i ];
766+ if (shouldDumpThreadStackTrace (thread )) {
767+ getErrorOutput ().println ("Thread: " + thread .getName ());
768+ StackTraceElement [] trace = thread .getStackTrace ();
769+ for (StackTraceElement traceElement : trace ) {
770+ getErrorOutput ().println ("\t at " + traceElement );
771+ }
772+ }
754773 }
774+ getErrorOutput ().println ("=== END OF DUMP ===" );
755775 }
756776 } catch (InterruptedException e ) {
757- e .printStackTrace ();
777+ e .printStackTrace (getErrorOutput () );
758778 }
759779 }
760780
781+ private boolean shouldDumpThreadStackTrace (Thread thread ) {
782+ // Dump any threads of the internal thread pool
783+ if (thread .getName ().startsWith (ObjectBoxThreadPool .THREAD_NAME_PREFIX )) return true ;
784+
785+ // Any other thread might be blocking a thread on the internal pool, so also dump any that appear to use
786+ // ObjectBox APIs.
787+ StackTraceElement [] trace = thread .getStackTrace ();
788+ for (StackTraceElement traceElement : trace ) {
789+ if (traceElement .getClassName ().contains (OBJECTBOX_PACKAGE_NAME )) return true ;
790+ }
791+
792+ return false ;
793+ }
794+
761795 /**
762796 * Danger zone! This will delete all data (files) of this BoxStore!
763797 * You must call {@link #close()} before and read the docs of that method carefully!
@@ -894,7 +928,7 @@ void txCommitted(Transaction tx, @Nullable int[] entityTypeIdsAffected) {
894928 synchronized (txCommitCountLock ) {
895929 commitCount ++; // Overflow is OK because we check for equality
896930 if (debugTxWrite ) {
897- System . out .println ("TX committed. New commit count: " + commitCount + ", entity types affected: " +
931+ getOutput () .println ("TX committed. New commit count: " + commitCount + ", entity types affected: " +
898932 (entityTypeIdsAffected != null ? entityTypeIdsAffected .length : 0 ));
899933 }
900934 }
@@ -1013,10 +1047,10 @@ public <T> T callInReadTxWithRetry(Callable<T> callable, int attempts, int initi
10131047 String diagnose = diagnose ();
10141048 String message = attempt + " of " + attempts + " attempts of calling a read TX failed:" ;
10151049 if (logAndHeal ) {
1016- System . err .println (message );
1050+ getErrorOutput () .println (message );
10171051 e .printStackTrace ();
1018- System . err .println (diagnose );
1019- System . err .flush ();
1052+ getErrorOutput () .println (diagnose );
1053+ getErrorOutput () .flush ();
10201054
10211055 System .gc ();
10221056 System .runFinalization ();
@@ -1121,7 +1155,7 @@ public <R> R callInTxNoException(Callable<R> callable) {
11211155 * See also {@link #runInTx(Runnable)}.
11221156 */
11231157 public void runInTxAsync (final Runnable runnable , @ Nullable final TxCallback <Void > callback ) {
1124- threadPool . submit (() -> {
1158+ internalScheduleThread (() -> {
11251159 try {
11261160 runInTx (runnable );
11271161 if (callback != null ) {
@@ -1142,7 +1176,7 @@ public void runInTxAsync(final Runnable runnable, @Nullable final TxCallback<Voi
11421176 * * See also {@link #callInTx(Callable)}.
11431177 */
11441178 public <R > void callInTxAsync (final Callable <R > callable , @ Nullable final TxCallback <R > callback ) {
1145- threadPool . submit (() -> {
1179+ internalScheduleThread (() -> {
11461180 try {
11471181 R result = callInTx (callable );
11481182 if (callback != null ) {
@@ -1314,11 +1348,11 @@ public void setDbExceptionListener(@Nullable DbExceptionListener dbExceptionList
13141348
13151349 @ Internal
13161350 public Future <?> internalScheduleThread (Runnable runnable ) {
1317- return threadPool .submit (runnable );
1351+ return internalThreadPool () .submit (runnable );
13181352 }
13191353
13201354 @ Internal
1321- public ExecutorService internalThreadPool () {
1355+ ExecutorService internalThreadPool () {
13221356 return threadPool ;
13231357 }
13241358
@@ -1337,6 +1371,20 @@ public TxCallback<?> internalFailedReadTxAttemptCallback() {
13371371 return failedReadTxAttemptCallback ;
13381372 }
13391373
1374+ /**
1375+ * The output stream to print log messages to. Currently {@link System#out}.
1376+ */
1377+ private PrintStream getOutput () {
1378+ return System .out ;
1379+ }
1380+
1381+ /**
1382+ * The error output stream to print log messages to. This is {@link System#err} by default.
1383+ */
1384+ private PrintStream getErrorOutput () {
1385+ return errorOutputStream ;
1386+ }
1387+
13401388 void setDebugFlags (int debugFlags ) {
13411389 nativeSetDebugFlags (getNativeStore (), debugFlags );
13421390 }
0 commit comments