diff --git a/src/main/java/amidst/AmidstSettings.java b/src/main/java/amidst/AmidstSettings.java index 76561b579..863864170 100644 --- a/src/main/java/amidst/AmidstSettings.java +++ b/src/main/java/amidst/AmidstSettings.java @@ -37,6 +37,8 @@ public class AmidstSettings { public final Setting showDebug; public final Setting useHybridScaling; public final Setting threads; + public final Setting offscreenCacheTime; + public final Setting availableCacheTime; public final Setting lookAndFeel; public final Setting lastProfile; @@ -76,6 +78,8 @@ public AmidstSettings(Preferences preferences) { showDebug = Setting.createBoolean( preferences, "showDebug", false); useHybridScaling = Setting.createBoolean( preferences, "useHybridScaling", true); threads = Setting.createInteger( preferences, "threads", (Runtime.getRuntime().availableProcessors() / 2) + 1); + offscreenCacheTime = Setting.createInteger( preferences, "offscreenCacheTime", 30000); + availableCacheTime = Setting.createInteger( preferences, "availableCacheTime", 60000); lookAndFeel = Setting.createEnum( preferences, "lookAndFeel", AmidstLookAndFeel.DEFAULT); lastProfile = Setting.createString( preferences, "profile", ""); diff --git a/src/main/java/amidst/PerApplicationInjector.java b/src/main/java/amidst/PerApplicationInjector.java index a2a9794e6..7aca30d97 100644 --- a/src/main/java/amidst/PerApplicationInjector.java +++ b/src/main/java/amidst/PerApplicationInjector.java @@ -74,7 +74,12 @@ public PerApplicationInjector(CommandLineParameters parameters, AmidstMetaData m .createLocalAndStartDownloadingRemote(threadMaster.getWorkerExecutor()); this.layerBuilder = new LayerBuilder(); this.zoom = new Zoom(settings.maxZoom); - this.fragmentManager = new FragmentManager(layerBuilder.getConstructors(), layerBuilder.getNumberOfLayers(), settings.threads); + this.fragmentManager = new FragmentManager( + layerBuilder.getConstructors(), + layerBuilder.getNumberOfLayers(), + settings.threads, + settings.availableCacheTime, + settings.offscreenCacheTime); this.biomeSelection = new BiomeSelection(); this.application = new Application( preferredLauncherProfile, diff --git a/src/main/java/amidst/fragment/AvailableFragmentCache.java b/src/main/java/amidst/fragment/AvailableFragmentCache.java new file mode 100644 index 000000000..0affc6995 --- /dev/null +++ b/src/main/java/amidst/fragment/AvailableFragmentCache.java @@ -0,0 +1,96 @@ +package amidst.fragment; + +import java.util.Iterator; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.function.Consumer; + +import amidst.documentation.AmidstThread; +import amidst.documentation.CalledOnlyBy; +import amidst.documentation.NotThreadSafe; +import amidst.fragment.constructor.FragmentConstructor; +import amidst.settings.Setting; +import amidst.util.SoftExpiringReference; + +@NotThreadSafe +public class AvailableFragmentCache { + private final ConcurrentLinkedDeque> cache = new ConcurrentLinkedDeque<>(); + + private final Iterable constructors; + private final int numberOfLayers; + private final Setting availableCacheTime; + + @CalledOnlyBy(AmidstThread.EDT) + public AvailableFragmentCache( + Iterable constructors, + int numberOfLayers, + Setting availableCacheTime) { + this.constructors = constructors; + this.numberOfLayers = numberOfLayers; + this.availableCacheTime = availableCacheTime; + } + + @CalledOnlyBy(AmidstThread.EDT) + public Fragment getOrCreate() { + Fragment fragment = null; + while(!isEmpty() && (fragment = poll()) == null); // try to retrieve a non-null fragment from the cache + + if(fragment == null) { + fragment = new Fragment(numberOfLayers); + construct(fragment); + } + + return fragment; + } + + @CalledOnlyBy(AmidstThread.EDT) + private Fragment poll() { + SoftExpiringReference value = (SoftExpiringReference) cache.pollFirst(); + return value != null ? value.getValue() : null; + } + + /** + * Fragments that are used when calling this method should already + * be recycled. + */ + @CalledOnlyBy(AmidstThread.EDT) + public void put(Fragment fragment) { + cache.addLast(new SoftExpiringReference<>(fragment, availableCacheTime.get())); + } + + @CalledOnlyBy(AmidstThread.EDT) + private void construct(Fragment fragment) { + for (FragmentConstructor constructor : constructors) { + constructor.construct(fragment); + } + } + + public synchronized void clean() { + clean(null); + } + + public synchronized void clean(Consumer expiredConsumer) { + Iterator> fragRefIterator = cache.iterator(); + for (SoftExpiringReference fragRef : (Iterable>) () -> fragRefIterator) { + Fragment realFrag = null; + boolean isNull = (fragRef == null || (realFrag = fragRef.getValue()) == null); + + if (isNull || fragRef.getDelayMillis() < 0) { + fragRefIterator.remove(); + // pass to consumer if expired and not null + if(!isNull && expiredConsumer != null) { + expiredConsumer.accept(realFrag); + } + } + } + } + + @CalledOnlyBy(AmidstThread.EDT) + public int size() { + return cache.size(); + } + + @CalledOnlyBy(AmidstThread.EDT) + public boolean isEmpty() { + return cache.size() <= 0; + } +} diff --git a/src/main/java/amidst/fragment/Fragment.java b/src/main/java/amidst/fragment/Fragment.java index c9beb287b..3db04548f 100644 --- a/src/main/java/amidst/fragment/Fragment.java +++ b/src/main/java/amidst/fragment/Fragment.java @@ -22,36 +22,19 @@ * setters.
*
* The life-cycle of a Fragment is quite complex to prevent the garbage - * collection from running too often. When a fragment is no longer needed it - * will be kept available in a queue, so it can be reused later on. The - * life-cycle consists of the three flags: isInitialized, isLoading, and - * isLoaded. isInitialized can be set to true from any thread, however setting - * isInitialized to false as well as any modification to isLoading and isLoaded - * will always be called from the fragment loading thread, to ensure a - * consistent state. Also, isInitialized will only be set to true again after it - * was set to false. It is not possible that isLoading or isLoaded is true while - * isInitialized is false. It is also not possible for isLoaded to be true while - * isLoading is true.
- *
- * It is possible that a thread that uses the data in the fragment continues to - * use them after isLoaded is set to false. However, all write operations are - * called from either the fragment loading thread, the threads from - * {@link FragmentManager#fragWorkers}, or the EDT during theconstruction of - * the fragment. While the fragment is constructed it will only be accessible - * by one thread. An exception to that rule is the instance variable alpha. - * It is altered from the drawing thread, however this should not cause any - * issues.
- *
- * Immediately after a new instance of this class is created, it is passed to - * all FragmentConstructors. At that point in time, no other thread can access - * the fragment, so the whole construction process is single-threaded. After the - * fragment is constructed it will be available to use in the fragment graph. As - * soon as it is requested, its isInitialized variable will be set to true by - * the requesting thread. Also, it is enqueued to the loading queue. Note, that - * the fragment is still not loaded, but used in the fragment graph and thus + * collection from running too often. The cycle consists of four states: + * uninitialized, initialized, loading, and loaded. These all govern what can + * be done to the fragment and any given time. A fragment starts off as + * uninitialized on creation. Immediately after a new fragment is created, it + * is passed to all FragmentConstructors by the EDT. At that point in time, no + * other thread can access the fragment, so the whole construction process is + * single-threaded. After the fragment is constructed, its state gets set to + * initialized, and is put in both the fragment graph in the loading queue. Note, + * that the fragment is still not loaded, but used in the fragment graph and thus * used by the {@link Drawer}. Sometime after the fragment was requested, it - * will go through the loading process, because it was enqueued to the loading - * queue.
+ * will go through the loading process because it was enqueued to the loading + * queue. The fragment loader and worker threads are the only threads + * that can modify its state beyond this point.
*
* During the loading process, the fragment loader thread will first check to * make sure that there are threads open in the fragment worker thread pool. If @@ -61,31 +44,26 @@ * is set to true. This is to make sure that only one thread is loading the * fragment at a time.
*
- * When this is done, the isLoaded variable will be set to true. This allows the + * When this is done, the state will be set to loaded. This allows the * drawer to actually draw the fragment. The complete drawing process is * executed in the event dispatch thread. When the fragment is no longer visible - * on the screen it will be removed from the fragment graph. However, since it - * holds a data-structure that is quite heavy to allocate and garbage-collect, - * the fragment will be recycled so it can be reused later. This recycling is - * done by enqueuing the fragment to the recycle queue. The recycle queue is - * processed by the fragment loading thread with a very high priority. Even - * though the fragment loading thread only calls the method - * {@link Fragment#recycle()} and enqueues the fragment to the available queue, - * it is important that this is done by the fragment loading queue. This is, - * because if any other thread sets the isLoaded variable to false, it might be - * set to true by the fragment loading thread afterwards, because the fragment - * was not yet loaded. This problem is solved by modifying the isLoaded variable - * only in the fragment loading thread. This issue only arises, since the - * fragment is already used in the fragment graph, before it is loaded. As soon - * as it is used in the fragment graph it, can be recycled. This often leads to - * a situation where a not yet loaded fragment gets recycled. The isInitialized - * variable is altered by the thread that requests the new fragment, which is - * different from the fragment loading thread. This is not an issue, since all - * fragments in the available queue have both variables isInitialized and - * isLoaded set to false. They are also not used in the fragment graph or - * enqueued in the recycle queue. Therefore, there cannot be a race condition - * because the isInitialized variable will only be set to false when it is - * recycled. + * on the screen, a check is done to determine how to use it. If the fragment is + * loading or loaded, it gets sent to the off-screen cache, where it will be able + * to exist for a set amount of time without being refreshed before it gets + * recycled. If the fragment isn't loaded or loading, it also gets recycled. + * When a fragment is recycled, its state gets reset to initialized and it gets + * sent to the available cache to be re-used. If a fragment stays in the available + * queue for too long, it gets cleared and eventually garbage collected.
+ *
+ * It is possible that a thread that uses the data in the fragment continues to + * use them after its state gets reset to uninitialized. However, all write + * operations are called from either the fragment loading thread, the threads from + * {@link FragmentManager#fragWorkers}, or the EDT during the construction of + * the fragment. While the fragment is constructed it will only be accessible + * by one thread. An exception to that rule is the instance variable alpha. + * It is altered from the drawing thread, however this should not cause any + * issues.
+ *
*/ @ThreadSafe public class Fragment { diff --git a/src/main/java/amidst/fragment/FragmentCache.java b/src/main/java/amidst/fragment/FragmentCache.java deleted file mode 100644 index 50c6af65d..000000000 --- a/src/main/java/amidst/fragment/FragmentCache.java +++ /dev/null @@ -1,80 +0,0 @@ -package amidst.fragment; - -import java.util.LinkedList; -import java.util.List; -import java.util.concurrent.ConcurrentLinkedQueue; - -import amidst.documentation.AmidstThread; -import amidst.documentation.CalledOnlyBy; -import amidst.documentation.ThreadSafe; -import amidst.fragment.constructor.FragmentConstructor; -import amidst.logging.AmidstLogger; - -@ThreadSafe -public class FragmentCache { - private static final int NEW_FRAGMENTS_PER_REQUEST = 1024; - - private final List cache = new LinkedList<>(); - - private final ConcurrentLinkedQueue availableQueue; - private final ConcurrentLinkedQueue loadingQueue; - private final Iterable constructors; - private final int numberOfLayers; - - @CalledOnlyBy(AmidstThread.EDT) - public FragmentCache( - ConcurrentLinkedQueue availableQueue, - ConcurrentLinkedQueue loadingQueue, - Iterable constructors, - int numberOfLayers) { - this.availableQueue = availableQueue; - this.loadingQueue = loadingQueue; - this.constructors = constructors; - this.numberOfLayers = numberOfLayers; - } - - @CalledOnlyBy(AmidstThread.EDT) - public synchronized void increaseSize() { - AmidstLogger.info( - "increasing fragment cache size from " + cache.size() + " to " - + (cache.size() + NEW_FRAGMENTS_PER_REQUEST)); - requestNewFragments(); - AmidstLogger.info("fragment cache size increased to " + cache.size()); - } - - @CalledOnlyBy(AmidstThread.EDT) - private void requestNewFragments() { - for (int i = 0; i < NEW_FRAGMENTS_PER_REQUEST; i++) { - Fragment fragment = new Fragment(numberOfLayers); - construct(fragment); - cache.add(fragment); - availableQueue.offer(fragment); - } - } - - @CalledOnlyBy(AmidstThread.EDT) - private void construct(Fragment fragment) { - for (FragmentConstructor constructor : constructors) { - constructor.construct(fragment); - } - } - - @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) - public synchronized void reloadAll() { - loadingQueue.clear(); - for (Fragment fragment : cache) { - loadingQueue.offer(fragment); - } - } - - @CalledOnlyBy(AmidstThread.EDT) - public synchronized void clear() { - AmidstLogger.info("fragment cache cleared"); - cache.clear(); - } - - @CalledOnlyBy(AmidstThread.EDT) - public int size() { - return cache.size(); - } -} diff --git a/src/main/java/amidst/fragment/FragmentGraph.java b/src/main/java/amidst/fragment/FragmentGraph.java index a6673ac5d..187cf454c 100644 --- a/src/main/java/amidst/fragment/FragmentGraph.java +++ b/src/main/java/amidst/fragment/FragmentGraph.java @@ -58,6 +58,7 @@ public void dispose() { @CalledOnlyBy(AmidstThread.EDT) private void recycleAll() { + fragmentManager.invalidateCaches(); topLeftFragment.ifInitialized(f -> f.recycleAll(fragmentManager)); } diff --git a/src/main/java/amidst/fragment/FragmentGraphItem.java b/src/main/java/amidst/fragment/FragmentGraphItem.java index c05f33195..83ef2584a 100644 --- a/src/main/java/amidst/fragment/FragmentGraphItem.java +++ b/src/main/java/amidst/fragment/FragmentGraphItem.java @@ -214,7 +214,7 @@ private void deleteFirstRow(FragmentManager manager) { while (current != null) { current.disconnectBelow(); FragmentGraphItem right = current.disconnectRight(); - current.recycle(manager); + current.retire(manager); current = right; } } @@ -225,7 +225,7 @@ private void deleteLastRow(FragmentManager manager) { while (current != null) { current.disconnectAbove(); FragmentGraphItem right = current.disconnectRight(); - current.recycle(manager); + current.retire(manager); current = right; } } @@ -236,7 +236,7 @@ private void deleteFirstColumn(FragmentManager manager) { while (current != null) { current.disconnectRight(); FragmentGraphItem below = current.disconnectBelow(); - current.recycle(manager); + current.retire(manager); current = below; } } @@ -247,7 +247,7 @@ private void deleteLastColumn(FragmentManager manager) { while (current != null) { current.disconnectLeft(); FragmentGraphItem below = current.disconnectBelow(); - current.recycle(manager); + current.retire(manager); current = below; } } @@ -382,7 +382,7 @@ private FragmentGraphItem createFragmentGraphItem(FragmentManager manager, int x } @CalledOnlyBy(AmidstThread.EDT) - private void recycle(FragmentManager manager) { - manager.recycleFragment(fragment); + private void retire(FragmentManager manager) { + manager.retireFragment(fragment); } } diff --git a/src/main/java/amidst/fragment/FragmentManager.java b/src/main/java/amidst/fragment/FragmentManager.java index fecf754cd..fefa9f557 100644 --- a/src/main/java/amidst/fragment/FragmentManager.java +++ b/src/main/java/amidst/fragment/FragmentManager.java @@ -1,9 +1,12 @@ package amidst.fragment; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import amidst.documentation.AmidstThread; import amidst.documentation.CalledOnlyBy; @@ -16,21 +19,31 @@ @NotThreadSafe public class FragmentManager { - private final ConcurrentLinkedQueue availableQueue = new ConcurrentLinkedQueue<>(); private final ConcurrentLinkedQueue loadingQueue = new ConcurrentLinkedQueue<>(); - private final ConcurrentLinkedQueue recycleQueue = new ConcurrentLinkedQueue<>(); - private final FragmentCache cache; + private final ConcurrentLinkedDeque recycleQueue = new ConcurrentLinkedDeque<>(); + + private final AvailableFragmentCache availableCache; + private final OffScreenFragmentCache offscreenCache; private final Setting threadsSetting; + private final ScheduledExecutorService cleanerThread; private ThreadPoolExecutor fragWorkers; @CalledOnlyBy(AmidstThread.EDT) - public FragmentManager(Iterable constructors, int numberOfLayers, Setting threadsSetting) { - this.cache = new FragmentCache(availableQueue, loadingQueue, constructors, numberOfLayers); + public FragmentManager( + Iterable constructors, + int numberOfLayers, + Setting threadsSetting, + Setting availableCacheTime, + Setting offscreenCacheTime) { + this.availableCache = new AvailableFragmentCache(constructors, numberOfLayers, availableCacheTime); + this.offscreenCache = new OffScreenFragmentCache(recycleQueue, offscreenCacheTime); this.threadsSetting = threadsSetting; + this.cleanerThread = createCleanerThread(); this.fragWorkers = createThreadPool(); } - + + @CalledOnlyBy(AmidstThread.EDT) public ThreadPoolExecutor createThreadPool() { return (ThreadPoolExecutor) Executors.newFixedThreadPool(threadsSetting.get(), new ThreadFactory() { private int num; @@ -42,38 +55,90 @@ public Thread newThread(Runnable r) { }); } + private ScheduledExecutorService createCleanerThread() { + ScheduledExecutorService scheduledExecutor = + Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "CacheCleanerThread")); + scheduledExecutor.scheduleAtFixedRate(() -> { + offscreenCache.cleanAndRecycle(); + availableCache.clean(); + }, 0, 500, TimeUnit.MILLISECONDS); // cleans both caches 2 times a second + return scheduledExecutor; + } + + @CalledOnlyBy(AmidstThread.EDT) + public void restart() { + fragWorkers.shutdownNow(); + this.fragWorkers = createThreadPool(); + } + + @CalledOnlyBy(AmidstThread.EDT) + public void shutdownCleaner() { + cleanerThread.shutdownNow(); + } + + @CalledOnlyBy(AmidstThread.EDT) + public void invalidateCaches() { + offscreenCache.invalidate(); + } + @CalledOnlyBy(AmidstThread.EDT) public Fragment requestFragment(CoordinatesInWorld coordinates) { - Fragment fragment; - while ((fragment = availableQueue.poll()) == null) { - cache.increaseSize(); + // We get and remove the fragments that come on screen from the loading cache, + // returning it if it exists. + Fragment fragment = offscreenCache.remove(coordinates); + if (fragment != null) { + return fragment; } + + fragment = availableCache.getOrCreate(); fragment.setCorner(coordinates); fragment.setState(Fragment.State.INITIALIZED); loadingQueue.offer(fragment); return fragment; } + /** + * Called when a fragment is no longer shown on the screen. + */ @CalledOnlyBy(AmidstThread.EDT) - public void recycleFragment(Fragment fragment) { - recycleQueue.offer(fragment); + public void retireFragment(Fragment fragment) { + // This isn't recycled normally because we want to have a different outcome if it fails the first try + if (fragment.tryRecycleNotLoaded()) { + // We don't want this to load while it's in the availableCache so we remove it from the loadingQueue + while (loadingQueue.remove(fragment)); + availableCache.put(fragment); + } else { + // We store the fragment if it's loaded or not properly recycling and it goes offscreen + // If it doesn't properly recycle, we know it'll just get loaded by the loadingQueue anyway + offscreenCache.put(fragment); + } } @CalledOnlyBy(AmidstThread.EDT) - public FragmentQueueProcessor createQueueProcessor(LayerManager layerManager, Setting dimensionSetting) { + public FragmentQueueProcessor createQueueProcessor( + LayerManager layerManager, + Setting dimensionSetting, + FragmentGraph graph) { + return new FragmentQueueProcessor( - availableQueue, loadingQueue, recycleQueue, - cache, + availableCache, + offscreenCache, layerManager, - fragWorkers, - dimensionSetting); + dimensionSetting, + graph, + fragWorkers); } @CalledOnlyBy(AmidstThread.EDT) - public int getAvailableQueueSize() { - return availableQueue.size(); + public int getAvailableCacheSize() { + return availableCache.size(); + } + + @CalledOnlyBy(AmidstThread.EDT) + public int getOffscreenCacheSize() { + return offscreenCache.size(); } @CalledOnlyBy(AmidstThread.EDT) @@ -88,20 +153,8 @@ public int getRecycleQueueSize() { @CalledOnlyBy(AmidstThread.EDT) public void clear() { - cache.clear(); - availableQueue.clear(); + offscreenCache.clear(); loadingQueue.clear(); recycleQueue.clear(); } - - @CalledOnlyBy(AmidstThread.EDT) - public void restartThreadPool() { - fragWorkers.shutdownNow(); - this.fragWorkers = createThreadPool(); - } - - @CalledOnlyBy(AmidstThread.EDT) - public int getCacheSize() { - return cache.size(); - } } diff --git a/src/main/java/amidst/fragment/FragmentQueueProcessor.java b/src/main/java/amidst/fragment/FragmentQueueProcessor.java index 30e6e93a6..3cac672a5 100644 --- a/src/main/java/amidst/fragment/FragmentQueueProcessor.java +++ b/src/main/java/amidst/fragment/FragmentQueueProcessor.java @@ -1,9 +1,15 @@ package amidst.fragment; +import java.util.Iterator; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.locks.LockSupport; +import javax.swing.SwingUtilities; + import amidst.documentation.AmidstThread; import amidst.documentation.CalledByAny; import amidst.documentation.CalledOnlyBy; @@ -15,34 +21,55 @@ @NotThreadSafe public class FragmentQueueProcessor { - private final ConcurrentLinkedQueue availableQueue; private final ConcurrentLinkedQueue loadingQueue; - private final ConcurrentLinkedQueue recycleQueue; - private final FragmentCache cache; + private final ConcurrentLinkedDeque recycleQueue; + private final AvailableFragmentCache availableCache; + private final OffScreenFragmentCache offscreenCache; + private final LayerManager layerManager; private final ThreadPoolExecutor fragWorkers; private final Setting dimensionSetting; + private final FragmentGraph graph; @CalledByAny public FragmentQueueProcessor( - ConcurrentLinkedQueue availableQueue, ConcurrentLinkedQueue loadingQueue, - ConcurrentLinkedQueue recycleQueue, - FragmentCache cache, + ConcurrentLinkedDeque recycleQueue, + AvailableFragmentCache availableCache, + OffScreenFragmentCache offscreenCache, LayerManager layerManager, - ThreadPoolExecutor fragWorkers, - Setting dimensionSetting) { - this.availableQueue = availableQueue; + Setting dimensionSetting, + FragmentGraph graph, + ThreadPoolExecutor fragWorkers) { this.loadingQueue = loadingQueue; this.recycleQueue = recycleQueue; - this.cache = cache; + this.availableCache = availableCache; + this.offscreenCache = offscreenCache; + this.layerManager = layerManager; this.dimensionSetting = dimensionSetting; + this.graph = graph; this.fragWorkers = fragWorkers; } - + + /** + * Returns if there are fragments that still need to be processed. + */ + @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) + private boolean hasFragments() { + return !loadingQueue.isEmpty(); + } + + /** + * Return the next fragment the loader should process, or null if no more fragments are available. + */ + @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) + private Fragment getNextFragment() { + return loadingQueue.poll(); + } + private static final int PARK_MILLIS = 1000; - + /** * It is important that the dimension setting is the same while a fragment * is loaded by different fragment loaders. This is why the dimension @@ -63,10 +90,10 @@ public void processQueues() { * as possible. */ int maxSize = fragWorkers.getMaximumPoolSize(); - while (loadingQueue.isEmpty() == false) { + while (hasFragments()) { if (fragWorkers.getActiveCount() < maxSize) { fragWorkers.execute(() -> { - Fragment f = loadingQueue.poll(); + Fragment f = getNextFragment(); if (f != null && dimension.equals(dimensionSetting.get())) { loadFragment(dimension, f); updateLayerManager(dimension); @@ -75,7 +102,8 @@ public void processQueues() { } }); } else { - LockSupport.parkNanos(PARK_MILLIS * 1000000); // if for some reason unpark was never called, unpark after time expires + // if for some reason unpark was never called, unpark after time expires + LockSupport.parkNanos(PARK_MILLIS * 1000000); } } layerManager.clearInvalidatedLayers(); @@ -84,15 +112,53 @@ public void processQueues() { @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) private void updateLayerManager(Dimension dimension) { if (layerManager.updateAll(dimension)) { - cache.reloadAll(); + tryReloadGraph(); + offscreenCache.invalidate(); } } + private volatile boolean firstLoad = true; + + /** + * Gets all of the fragments currently on the graph to offer, as they aren't + * stored in any cache. However, we don't want to do this on the first load. + */ + @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) + private synchronized void tryReloadGraph() { + if (firstLoad) { + firstLoad = false; + return; + } + + loadingQueue.clear(); + for (FragmentGraphItem graphItem : getGraphIterable()) { + loadingQueue.offer(graphItem.getFragment()); + } + } + + @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) + private Iterable getGraphIterable() { + CompletableFuture> graphIteratorFuture = new CompletableFuture<>(); + // we have to get this on the EDT because it isn't thread safe + SwingUtilities.invokeLater(() -> graphIteratorFuture.complete(graph.iterator())); + + return () -> { + try { + return graphIteratorFuture.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + }; + } + @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) private void processRecycleQueue() { Fragment fragment; while ((fragment = recycleQueue.poll()) != null) { - recycleFragment(fragment); + // the fragment goes back in the queue if it didn't fully recycle + if (!recycleFragment(fragment)) { + recycleQueue.addLast(fragment); + } } } @@ -100,8 +166,7 @@ private void processRecycleQueue() { private void loadFragment(Dimension dimension, Fragment fragment) { if (fragment.getState().equals(Fragment.State.LOADED)) { layerManager.reloadInvalidated(dimension, fragment); - } else if (!fragment.getState().equals(Fragment.State.UNINITIALIZED) - && !fragment.getAndSetState(Fragment.State.LOADING).equals(Fragment.State.LOADING)) { + } else if (!fragment.getAndSetState(Fragment.State.LOADING).equals(Fragment.State.LOADING)) { //If it's not loading, set loading and continue. If it is already loading, don't continue. layerManager.loadAll(dimension, fragment); fragment.setState(State.LOADED); @@ -109,20 +174,15 @@ private void loadFragment(Dimension dimension, Fragment fragment) { } @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) - private void recycleFragment(Fragment fragment) { - if (fragment.tryRecycle()) { - removeFromLoadingQueue(fragment); - availableQueue.offer(fragment); - } - } - - // TODO: Check performance with and without this. It is not needed, since - // loadFragment checks for isInitialized(). It helps to keep the - // loadingQueue small, but it costs time to remove fragments from the queue. - @CalledOnlyBy(AmidstThread.FRAGMENT_LOADER) - private void removeFromLoadingQueue(Object fragment) { - while (loadingQueue.remove(fragment)) { - // noop + private boolean recycleFragment(Fragment fragment) { + boolean recycled = fragment.tryRecycle(); + if (recycled) { + // We also don't want this to load while it's in the availableCache so we remove it from the loadingQueue + while (loadingQueue.remove(fragment)); + availableCache.put(fragment); } + + // if the recycle fails, it just ends up getting loaded and then recycled later + return recycled; } } diff --git a/src/main/java/amidst/fragment/OffScreenFragmentCache.java b/src/main/java/amidst/fragment/OffScreenFragmentCache.java new file mode 100644 index 000000000..bbd8a43f7 --- /dev/null +++ b/src/main/java/amidst/fragment/OffScreenFragmentCache.java @@ -0,0 +1,70 @@ +package amidst.fragment; + +import java.util.concurrent.ConcurrentLinkedDeque; + +import amidst.documentation.AmidstThread; +import amidst.documentation.CalledOnlyBy; +import amidst.documentation.NotThreadSafe; +import amidst.mojangapi.world.coordinates.CoordinatesInWorld; +import amidst.settings.Setting; +import amidst.util.SelfExpiringSoftHashMap; + +/** + * This contains fragments that have been loaded and are currently off-screen. + */ +@NotThreadSafe +public class OffScreenFragmentCache { + private final SelfExpiringSoftHashMap cache = new SelfExpiringSoftHashMap<>(); + private final ConcurrentLinkedDeque recycleQueue; + private final Setting offscreenCacheTime; + + public OffScreenFragmentCache(ConcurrentLinkedDeque recycleQueue, Setting offscreenCacheTime) { + this.recycleQueue = recycleQueue; + this.offscreenCacheTime = offscreenCacheTime; + } + + @CalledOnlyBy(AmidstThread.EDT) + public Fragment get(CoordinatesInWorld coordinates) { + return cache.get(coordinates); + } + + @CalledOnlyBy(AmidstThread.EDT) + public void put(Fragment fragment) { + if (fragment != null) { + Fragment frag = get(fragment.getCorner()); + if (frag == null) { + cache.put(fragment.getCorner(), fragment, offscreenCacheTime.get()); + } + } + } + + @CalledOnlyBy(AmidstThread.EDT) + public Fragment remove(CoordinatesInWorld coordinates) { + return cache.remove(coordinates); + } + + @CalledOnlyBy(AmidstThread.EDT) + public void invalidate() { + // When we invalidate the cache, we can recycle the fragments + recycleQueue.addAll(cache.values()); + clear(); + } + + @CalledOnlyBy(AmidstThread.EDT) + public void clear() { + cache.clear(); + } + + public void clean() { + cache.clean(); + } + + public void cleanAndRecycle() { + cache.clean(f -> recycleQueue.add(f)); + } + + @CalledOnlyBy(AmidstThread.EDT) + public int size() { + return cache.size(); + } +} diff --git a/src/main/java/amidst/gui/main/menu/AmidstMenuBuilder.java b/src/main/java/amidst/gui/main/menu/AmidstMenuBuilder.java index e3bb039fb..db22a8e06 100644 --- a/src/main/java/amidst/gui/main/menu/AmidstMenuBuilder.java +++ b/src/main/java/amidst/gui/main/menu/AmidstMenuBuilder.java @@ -1,8 +1,10 @@ package amidst.gui.main.menu; +import java.awt.FlowLayout; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.text.ParseException; import java.util.ArrayList; import java.util.Hashtable; import java.util.List; @@ -13,8 +15,14 @@ import javax.swing.JMenuBar; import javax.swing.JMenuItem; import javax.swing.JSlider; +import javax.swing.JSpinner; import javax.swing.MenuSelectionManager; +import javax.swing.SpinnerNumberModel; import javax.swing.UIManager; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.text.DefaultFormatterFactory; +import javax.swing.text.NumberFormatter; import amidst.AmidstSettings; import amidst.FeatureToggles; @@ -135,6 +143,7 @@ private JMenu create_Settings() { result.addSeparator(); result.add(create_Settings_LookAndFeel()); result.add(create_Settings_Threads()); + result.add(create_Settings_FragmentCache()); return result; } @@ -161,7 +170,7 @@ private JMenu create_Settings_LookAndFeel() { radios.addAll(Menus.radios(result, lookAndFeelSetting, AmidstLookAndFeel.values())); return result; } - + private JMenu create_Settings_Threads() { UIManager.put("Slider.focus", UIManager.get("Slider.background")); int cores = Runtime.getRuntime().availableProcessors(); @@ -178,7 +187,7 @@ public void mouseReleased(MouseEvent e) { actions.tryChangeThreads(); } } - } + } }); slider.setMinorTickSpacing(1); slider.setPaintTicks(true); @@ -192,6 +201,104 @@ public void mouseReleased(MouseEvent e) { return submenu; } + private JMenu create_Settings_FragmentCache() { + JMenu submenu = new JMenu("Cache Options"); + submenu.setLayout(new FlowLayout()); + + JLabel availableCacheTimeLabel = new JLabel("Available Cache Retention Time (s):"); + submenu.add(availableCacheTimeLabel); + + JSpinner availableCacheTimeSpinner = new JSpinner(createCacheTimeNumberModel(settings.availableCacheTime.get() / 1000d)); + availableCacheTimeSpinner.setEditor(createCacheTimeNumberEditor(availableCacheTimeSpinner)); + submenu.add(availableCacheTimeSpinner); + availableCacheTimeSpinner.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + if (e.getSource() instanceof JSpinner) { + settings.availableCacheTime.set((int) (((Number) ((JSpinner) e.getSource()).getValue()).doubleValue() * 1000)); + } + } + }); + + JLabel offscreenCacheTimeLabel = new JLabel("Off-Screen Cache Retention Time (s):"); + submenu.add(offscreenCacheTimeLabel); + + JSpinner offscreenCacheTimeSpinner = new JSpinner(createCacheTimeNumberModel(settings.offscreenCacheTime.get() / 1000d)); + offscreenCacheTimeSpinner.setEditor(createCacheTimeNumberEditor(offscreenCacheTimeSpinner)); + submenu.add(offscreenCacheTimeSpinner); + offscreenCacheTimeSpinner.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + if (e.getSource() instanceof JSpinner) { + settings.offscreenCacheTime.set((int) (((Number) ((JSpinner) e.getSource()).getValue()).doubleValue() * 1000)); + } + } + }); + return submenu; + } + + private JSpinner.NumberEditor createCacheTimeNumberEditor(JSpinner spinner) { + return new JSpinner.NumberEditor(spinner) { + private static final long serialVersionUID = 3558355517740224788L; + + { + NumberFormatter formatter = new NumberFormatter() { + private static final long serialVersionUID = 5843793486873426981L; + + @Override + public Object stringToValue(String text) throws ParseException { + if (text.equals("Never Unload")) { + return 3601d; + } else { + return super.stringToValue(text); + } + } + + @Override + public String valueToString(Object value) throws ParseException { + if (value instanceof Double && ((Double) value) == 3601d) { + return "Never Unload"; + } else { + return super.valueToString(value); + } + } + }; + formatter.setMaximum(((SpinnerNumberModel) spinner.getModel()).getMaximum()); + formatter.setMinimum(((SpinnerNumberModel) spinner.getModel()).getMinimum()); + getTextField().setFormatterFactory(new DefaultFormatterFactory(formatter)); + } + }; + } + + private SpinnerNumberModel createCacheTimeNumberModel(double value) { + return new SpinnerNumberModel(value, 0, 3601, 1d) { + private static final long serialVersionUID = 400524834946107859L; + + @Override + public Object getNextValue() { + return incrValue(+1); + } + + @Override + public Object getPreviousValue() { + return incrValue(-1); + } + + @SuppressWarnings("unchecked") + private Object incrValue(int dir) { + double newValue = getNumber().doubleValue() + (getStepSize().doubleValue() * (double)dir); + if ((getMaximum() != null) && (getMaximum().compareTo(newValue) < 0)) { + return null; + } + if ((getMinimum() != null) && (getMinimum().compareTo(newValue) > 0)) { + return null; + } else { + return newValue; + } + } + }; + } + private JMenu create_Settings_BiomeProfile() { JMenu result = new JMenu("Biome Profile"); // @formatter:off diff --git a/src/main/java/amidst/gui/main/viewer/PerViewerFacadeInjector.java b/src/main/java/amidst/gui/main/viewer/PerViewerFacadeInjector.java index fb3c53f8f..45068bb70 100644 --- a/src/main/java/amidst/gui/main/viewer/PerViewerFacadeInjector.java +++ b/src/main/java/amidst/gui/main/viewer/PerViewerFacadeInjector.java @@ -104,7 +104,7 @@ public PerViewerFacadeInjector( .create(settings, world, biomeSelection, worldIconSelection, zoom, accelerationCounter); this.graph = new FragmentGraph(layerManager.getDeclarations(), fragmentManager); this.translator = new FragmentGraphToScreenTranslator(graph, zoom); - this.fragmentQueueProcessor = fragmentManager.createQueueProcessor(layerManager, settings.dimension); + this.fragmentQueueProcessor = fragmentManager.createQueueProcessor(layerManager, settings.dimension, graph); this.layerReloader = layerManager.createLayerReloader(world); this.progressEntryHolder = new AtomicReference>(); this.widgets = createWidgets( diff --git a/src/main/java/amidst/gui/main/viewer/ViewerFacade.java b/src/main/java/amidst/gui/main/viewer/ViewerFacade.java index 2215fdff0..956b14903 100644 --- a/src/main/java/amidst/gui/main/viewer/ViewerFacade.java +++ b/src/main/java/amidst/gui/main/viewer/ViewerFacade.java @@ -102,7 +102,7 @@ public void dispose() { zoom.skipFading(); zoom.reset(); fragmentManager.clear(); - fragmentManager.restartThreadPool(); + fragmentManager.restart(); } @CalledOnlyBy(AmidstThread.EDT) diff --git a/src/main/java/amidst/gui/main/viewer/widget/DebugWidget.java b/src/main/java/amidst/gui/main/viewer/widget/DebugWidget.java index a5ea49c59..733a4949c 100644 --- a/src/main/java/amidst/gui/main/viewer/widget/DebugWidget.java +++ b/src/main/java/amidst/gui/main/viewer/widget/DebugWidget.java @@ -44,8 +44,8 @@ protected List updateTextLines() { int rows = graph.getFragmentsPerColumn(); return Arrays.asList( "Fragment Manager:", - "Cache Size: " + fragmentManager.getCacheSize(), - "Available Queue Size: " + fragmentManager.getAvailableQueueSize(), + "Available Cache Size: " + fragmentManager.getAvailableCacheSize(), + "Off-Screen Cache Size: " + fragmentManager.getOffscreenCacheSize(), "Loading Queue Size: " + fragmentManager.getLoadingQueueSize(), "Recycle Queue Size: " + fragmentManager.getRecycleQueueSize(), "", diff --git a/src/main/java/amidst/util/SelfExpiringSoftHashMap.java b/src/main/java/amidst/util/SelfExpiringSoftHashMap.java new file mode 100644 index 000000000..0c793d9fc --- /dev/null +++ b/src/main/java/amidst/util/SelfExpiringSoftHashMap.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2019 Pierantonio Cangianiello + * + * MIT License + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package amidst.util; + +import java.util.AbstractMap.SimpleEntry; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import amidst.documentation.ThreadSafe; + +/** + * A HashMap which entries expires after the specified life time. The + * life-time can be defined on a per-value basis, or using a default + * one, that is passed to the constructor.
+ *
+ * This class was completely refactored to use SoftReferences for use + * with amidst. + * + * @author Pierantonio Cangianiello + * @param the Key type + * @param the Value type + */ +@ThreadSafe +public class SelfExpiringSoftHashMap implements Map { + + private final Map> internalMap; + + /** + * The default max life time in milliseconds. + * TODO: make this able to be set by the user + */ + private final long maxLifeTimeMillis; + + public SelfExpiringSoftHashMap() { + internalMap = new ConcurrentHashMap<>(); + this.maxLifeTimeMillis = Long.MAX_VALUE; + } + + public SelfExpiringSoftHashMap(long defaultMaxLifeTimeMillis) { + internalMap = new ConcurrentHashMap<>(); + this.maxLifeTimeMillis = defaultMaxLifeTimeMillis; + } + + public SelfExpiringSoftHashMap(long defaultMaxLifeTimeMillis, int initialCapacity) { + internalMap = new ConcurrentHashMap<>(initialCapacity); + this.maxLifeTimeMillis = defaultMaxLifeTimeMillis; + } + + public SelfExpiringSoftHashMap(long defaultMaxLifeTimeMillis, int initialCapacity, float loadFactor) { + internalMap = new ConcurrentHashMap<>(initialCapacity, loadFactor); + this.maxLifeTimeMillis = defaultMaxLifeTimeMillis; + } + + /** + * {@inheritDoc} + */ + @Override + public int size() { + return internalMap.size(); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isEmpty() { + return internalMap.isEmpty(); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean containsKey(Object key) { + return internalMap.containsKey(key); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean containsValue(Object value) { + return internalMap.containsValue(value); + } + + @Override + public V get(Object key) { + SoftExpiringReference valueRef = internalMap.get(key); + renewValue(valueRef); + return valueRef != null ? valueRef.getValue() : null; + } + + /** + * {@inheritDoc} + */ + @Override + public V put(K key, V value) { + return this.put(key, value, maxLifeTimeMillis); + } + + /** + * Associates the given key to the given value in this map, with the + * specified life times in milliseconds. + * + * @param key + * @param value + * @param lifeTimeMillis + * @return a previously associated object for the given key (if + * exists). + */ + public V put(K key, V value, long lifeTimeMillis) { + SoftExpiringReference newValue = new SoftExpiringReference(value, lifeTimeMillis); + SoftExpiringReference oldValue = internalMap.put(key, newValue); + if (oldValue != null) { + expireValue(oldValue); + } + return value; + } + + /** + * {@inheritDoc} + */ + @Override + public V remove(Object key) { + SoftExpiringReference removedValue = internalMap.remove(key); + expireValue(removedValue); + return removedValue != null ? removedValue.getValue() : null; + } + + /** + * {@inheritDoc} + */ + @Override + public void putAll(Map m) { + putAll(m, maxLifeTimeMillis); + } + + public void putAll(Map m, long lifeTimeMillis) { + for (Entry entry : m.entrySet()) { + SoftExpiringReference newValue = new SoftExpiringReference(entry.getValue(), lifeTimeMillis); + SoftExpiringReference oldValue = internalMap.put(entry.getKey(), newValue); + if (oldValue != null) { + expireValue(oldValue); + } + } + } + + /** + * Renews the specified value, setting the life time to the initial + * value. + * + * @param value + * @return true if the value is found and hasn't been dereferenced, + * false otherwise + */ + public boolean renewValue(SoftExpiringReference valueRef) { + if (valueRef != null && valueRef.getValue() != null) { + valueRef.renew(); + return true; + } + return false; + } + + private void expireValue(SoftExpiringReference valueRef) { + if (valueRef != null) { + valueRef.expire(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void clear() { + internalMap.clear(); + } + + /** + * {@inheritDoc} + */ + @Override + public Set keySet() { + return internalMap.keySet(); + } + + /** + * WARNING: This implementation does not reflect the changes + * made on the return value in the original Map.
+ *
+ * {@inheritDoc} + */ + @Override + public Collection values() { + return Collections.synchronizedSet(internalMap.values().stream().map(sv -> sv.getValue()).filter(v -> v != null).collect(Collectors.toSet())); + } + + /** + * WARNING: This implementation does not reflect the changes + * made on the return value in the original Map.
+ *
+ * {@inheritDoc} + */ + @Override + public Set> entrySet() { + return Collections.synchronizedSet(internalMap.entrySet().stream().map(e -> { + V realValue = e.getValue().getValue(); + if (realValue != null) { + return new SimpleEntry<>(e.getKey(), realValue); + } + return null; + }).filter(e -> e != null).collect(Collectors.toSet())); + } + + public void clean() { + clean(null); + } + + public synchronized void clean(Consumer expiredConsumer) { + Iterator> valueRefIterator = internalMap.values().iterator(); + for (SoftExpiringReference valueRef : (Iterable>) () -> valueRefIterator) { + V realValue = valueRef.getValue(); + boolean isNull = (realValue == null); + + if (isNull || valueRef.getDelayMillis() < 0) { + valueRefIterator.remove(); + // pass to consumer if expired and not null + if(!isNull && expiredConsumer != null) { + expiredConsumer.accept(realValue); + } + } + } + } +} diff --git a/src/main/java/amidst/util/SoftExpiringReference.java b/src/main/java/amidst/util/SoftExpiringReference.java new file mode 100644 index 000000000..6b63918ae --- /dev/null +++ b/src/main/java/amidst/util/SoftExpiringReference.java @@ -0,0 +1,60 @@ +package amidst.util; + +import java.lang.ref.SoftReference; + +public class SoftExpiringReference { + private long startTime = System.currentTimeMillis(); + private final long maxLifeTimeMillis; + private final SoftReference valueRef; + + public SoftExpiringReference(T value, long maxLifeTimeMillis) { + this.maxLifeTimeMillis = maxLifeTimeMillis; + this.valueRef = new SoftReference<>(value); + } + + public T getValue() { + return valueRef.get(); + } + + /** + * {@inheritDoc} + */ + @SuppressWarnings("unchecked") + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final SoftExpiringReference other = (SoftExpiringReference) obj; + if (this.getValue() != other.getValue() + && (this.getValue() == null || !this.getValue().equals(other.getValue()))) { + return false; + } + return true; + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + int hash = 7; + hash = 31 * hash + (this.getValue() != null ? this.getValue().hashCode() : 0); + return hash; + } + + public long getDelayMillis() { + return (startTime + maxLifeTimeMillis) - System.currentTimeMillis(); + } + + public void renew() { + startTime = System.currentTimeMillis(); + } + + public void expire() { + startTime = Long.MIN_VALUE; + } +} \ No newline at end of file