This is an automated email from the ASF dual-hosted git repository. ggregory pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/commons-io.git
commit 60b61dabc7927afaa4a7205046d889a2daef2293 Author: Gary Gregory <gardgreg...@gmail.com> AuthorDate: Sun Sep 19 21:56:28 2021 -0400 [IO-726] Add MemoryMappedInputStream, #215. Modified PR 215 from shollander with: - Do not write the console. - Simplify cleaner check support. - Simplify exception handling. - Don't initialize instance variables to their default values. - Un-nest else clause. - Declaring interfaces as static is redundant. - Use EOF constant instead of magic number. - Don't throw RuntimeException, use IllegalStateException. - Pacakge private class does not need public method. - Use final. - Improve Javadocs. - Javadoc sentences should end in a period. - Javadoc do not need first sentence to be in an HTML p element. - No need to use `this.` when you do not need to. - In-line some single use variables. - Sort members. --- src/changes/changes.xml | 3 + .../io/input/BufferedFileChannelInputStream.java | 82 +------ .../apache/commons/io/input/ByteBufferCleaner.java | 116 ++++++++++ .../io/input/MemoryMappedFileInputStream.java | 177 +++++++++++++++ .../commons/io/input/ByteBufferCleanerTest.java | 53 +++++ .../io/input/MemoryMappedFileInputStreamTest.java | 244 +++++++++++++++++++++ 6 files changed, 602 insertions(+), 73 deletions(-) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index ffb8987..ca910fa 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -74,6 +74,9 @@ The <action> type attribute can be add,update,fix,remove. <action dev="ggregory" type="fix" due-to="Arturo Bernal"> Fix Javadoc in ThreadMonitor#run() method. #273. </action> + <action issue="IO-726" dev="ggregory" type="fix" due-to="shollander, Gary Gregory"> + Add MemoryMappedInputStream. #215. + </action> <!-- ADD --> <action dev="ggregory" type="add" due-to="Gary Gregory"> Add BrokenReader.INSTANCE. diff --git a/src/main/java/org/apache/commons/io/input/BufferedFileChannelInputStream.java b/src/main/java/org/apache/commons/io/input/BufferedFileChannelInputStream.java index 95792c8..f9e6ad7 100644 --- a/src/main/java/org/apache/commons/io/input/BufferedFileChannelInputStream.java +++ b/src/main/java/org/apache/commons/io/input/BufferedFileChannelInputStream.java @@ -18,8 +18,6 @@ import static org.apache.commons.io.IOUtils.EOF; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.file.Path; @@ -40,29 +38,12 @@ import org.apache.commons.io.IOUtils; * * @since 2.9.0 */ -@SuppressWarnings("restriction") public final class BufferedFileChannelInputStream extends InputStream { private final ByteBuffer byteBuffer; private final FileChannel fileChannel; - private static final Class<?> DIRECT_BUFFER_CLASS = getDirectBufferClass(); - - private static Class<?> getDirectBufferClass() { - Class<?> res = null; - try { - res = Class.forName("sun.nio.ch.DirectBuffer"); - } catch (final IllegalAccessError | ClassNotFoundException ignored) { - // ignored - } - return res; - } - - private static boolean isDirectBuffer(final Object object) { - return DIRECT_BUFFER_CLASS != null && DIRECT_BUFFER_CLASS.isInstance(object); - } - /** * Constructs a new instance for the given File. * @@ -115,75 +96,30 @@ public final class BufferedFileChannelInputStream extends InputStream { /** * Attempts to clean up a ByteBuffer if it is direct or memory-mapped. This uses an *unsafe* Sun API that will cause - * errors if one attempts to read from the disposed buffer. However, neither the bytes allocated to direct buffers - * nor file descriptors opened for memory-mapped buffers put pressure on the garbage collector. Waiting for garbage + * errors if one attempts to read from the disposed buffer. However, neither the bytes allocated to direct buffers nor + * file descriptors opened for memory-mapped buffers put pressure on the garbage collector. Waiting for garbage * collection may lead to the depletion of off-heap memory or huge numbers of open files. There's unfortunately no * standard API to manually dispose of these kinds of buffers. * * @param buffer the buffer to clean. */ private void clean(final ByteBuffer buffer) { - if (isDirectBuffer(buffer)) { + if (buffer.isDirect()) { cleanDirectBuffer(buffer); } } /** - * In Java 8, the type of DirectBuffer.cleaner() was sun.misc.Cleaner, and it was possible to access the method - * sun.misc.Cleaner.clean() to invoke it. The type changed to jdk.internal.ref.Cleaner in later JDKs, and the - * .clean() method is not accessible even with reflection. However sun.misc.Unsafe added a invokeCleaner() method in - * JDK 9+ and this is still accessible with reflection. + * In Java 8, the type of {@code sun.nio.ch.DirectBuffer.cleaner()} was {@code sun.misc.Cleaner}, and it was possible to + * access the method {@code sun.misc.Cleaner.clean()} to invoke it. The type changed to {@code jdk.internal.ref.Cleaner} + * in later JDKs, and the {@code clean()} method is not accessible even with reflection. However {@code sun.misc.Unsafe} + * added an {@code invokeCleaner()} method in JDK 9+ and this is still accessible with reflection. * * @param buffer the buffer to clean. must be a DirectBuffer. */ private void cleanDirectBuffer(final ByteBuffer buffer) { - // - // Ported from StorageUtils.scala. - // -// private val bufferCleaner: DirectBuffer => Unit = -// if (SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_9)) { -// val cleanerMethod = -// Utils.classForName("sun.misc.Unsafe").getMethod("invokeCleaner", classOf[ByteBuffer]) -// val unsafeField = classOf[Unsafe].getDeclaredField("theUnsafe") -// unsafeField.setAccessible(true) -// val unsafe = unsafeField.get(null).asInstanceOf[Unsafe] -// buffer: DirectBuffer => cleanerMethod.invoke(unsafe, buffer) -// } else { -// val cleanerMethod = Utils.classForName("sun.misc.Cleaner").getMethod("clean") -// buffer: DirectBuffer => { -// // Careful to avoid the return type of .cleaner(), which changes with JDK -// val cleaner: AnyRef = buffer.cleaner() -// if (cleaner != null) { -// cleanerMethod.invoke(cleaner) -// } -// } -// } - // - final String specVer = System.getProperty("java.specification.version"); - if ("1.8".equals(specVer)) { - // On Java 8, but also compiles on Java 11. - try { - final Class<?> clsCleaner = Class.forName("sun.misc.Cleaner"); - final Method cleanerMethod = DIRECT_BUFFER_CLASS.getMethod("cleaner"); - final Object cleaner = cleanerMethod.invoke(buffer); - if (cleaner != null) { - final Method cleanMethod = clsCleaner.getMethod("clean"); - cleanMethod.invoke(cleaner); - } - } catch (final ReflectiveOperationException e) { - throw new IllegalStateException(e); - } - } else { - // On Java 9 and up, but compiles on Java 8. - try { - final Class<?> clsUnsafe = Class.forName("sun.misc.Unsafe"); - final Method cleanerMethod = clsUnsafe.getMethod("invokeCleaner", ByteBuffer.class); - final Field unsafeField = clsUnsafe.getDeclaredField("theUnsafe"); - unsafeField.setAccessible(true); - cleanerMethod.invoke(unsafeField.get(null), buffer); - } catch (final ReflectiveOperationException e) { - throw new IllegalStateException(e); - } + if (ByteBufferCleaner.isSupported()) { + ByteBufferCleaner.clean(buffer); } } diff --git a/src/main/java/org/apache/commons/io/input/ByteBufferCleaner.java b/src/main/java/org/apache/commons/io/input/ByteBufferCleaner.java new file mode 100644 index 0000000..bf59546 --- /dev/null +++ b/src/main/java/org/apache/commons/io/input/ByteBufferCleaner.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.io.input; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; + +/** + * Cleans a direct {@link ByteBuffer}. Without manual intervention, direct ByteBuffers will be cleaned eventually upon + * garbage collection. However, this should not be be relied upon since it may not occur in a timely fashion - + * especially since off heap ByeBuffers don't put pressure on the garbage collector. + * <p> + * <b>Warning:</b> Do not attempt to use a direct {@link ByteBuffer} that has been cleaned or bad things will happen. + * Don't use this class unless you can ensure that the cleaned buffer will not be accessed anymore. + * </p> + * <p> + * See <a href=https://bugs.openjdk.java.net/browse/JDK-4724038>JDK-4724038</a> + * </p> + */ +class ByteBufferCleaner { + + private interface Cleaner { + void clean(ByteBuffer buffer) throws ReflectiveOperationException; + } + + private static class Java8Cleaner implements Cleaner { + + private final Method cleanerMethod; + private final Method cleanMethod; + + private Java8Cleaner() throws ReflectiveOperationException, SecurityException { + cleanMethod = Class.forName("sun.misc.Cleaner").getMethod("clean"); + cleanerMethod = Class.forName("sun.nio.ch.DirectBuffer").getMethod("cleaner"); + } + + @Override + public void clean(final ByteBuffer buffer) throws ReflectiveOperationException { + final Object cleaner = cleanerMethod.invoke(buffer); + if (cleaner != null) { + cleanMethod.invoke(cleaner); + } + } + } + + private static class Java9Cleaner implements Cleaner { + + private final Object theUnsafe; + private final Method invokeCleaner; + + private Java9Cleaner() throws ReflectiveOperationException, SecurityException { + final Class<?> unsafeClass = Class.forName("sun.misc.Unsafe"); + final Field field = unsafeClass.getDeclaredField("theUnsafe"); + field.setAccessible(true); + theUnsafe = field.get(null); + invokeCleaner = unsafeClass.getMethod("invokeCleaner", ByteBuffer.class); + } + + @Override + public void clean(final ByteBuffer buffer) throws ReflectiveOperationException { + invokeCleaner.invoke(theUnsafe, buffer); + } + } + + private static final Cleaner INSTANCE = getCleaner(); + + /** + * Releases memory held by the given {@link ByteBuffer}. + * + * @param buffer to release. + * @throws IllegalStateException on internal failure. + */ + static void clean(final ByteBuffer buffer) { + try { + INSTANCE.clean(buffer); + } catch (final Exception e) { + throw new IllegalStateException("Failed to clean direct buffer.", e); + } + } + + private static Cleaner getCleaner() { + try { + return new Java8Cleaner(); + } catch (final Exception e) { + try { + return new Java9Cleaner(); + } catch (final Exception e1) { + throw new IllegalStateException("Failed to initialize a Cleaner.", e); + } + } + } + + /** + * Tests if were able to load a suitable cleaner for the current JVM. Attempting to call + * {@code ByteBufferCleaner#clean(ByteBuffer)} when this method returns false will result in an exception. + * + * @return {@code true} if cleaning is supported, {@code false} otherwise. + */ + static boolean isSupported() { + return INSTANCE != null; + } +} diff --git a/src/main/java/org/apache/commons/io/input/MemoryMappedFileInputStream.java b/src/main/java/org/apache/commons/io/input/MemoryMappedFileInputStream.java new file mode 100644 index 0000000..7be5c6a --- /dev/null +++ b/src/main/java/org/apache/commons/io/input/MemoryMappedFileInputStream.java @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.io.input; + +import static org.apache.commons.io.IOUtils.EOF; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileChannel.MapMode; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +/** + * An {@link InputStream} that utilizes memory mapped files to improve performance. A sliding window of the file is + * mapped to memory to avoid mapping the entire file to memory at one time. The size of the sliding buffer is + * configurable. + * <p> + * For most operating systems, mapping a file into memory is more expensive than reading or writing a few tens of + * kilobytes of data. From the standpoint of performance. it is generally only worth mapping relatively large files into + * memory. + * </p> + * <p> + * Note: Use of this class does not necessarily obviate the need to use a {@link BufferedInputStream}. Depending on the + * use case, the use of buffering may still further improve performance. For example: + * </p> + * <pre> + * new BufferedInputStream(new GzipInputStream(new MemoryMappedFileInputStream(path)))) + * </pre> + * <p> + * should outperform: + * </p> + * <pre> + * new GzipInputStream(new MemoryMappedFileInputStream(path)) + * </pre> + * + * @since 2.12.0 + */ +public class MemoryMappedFileInputStream extends InputStream { + + /** + * Default size of the sliding memory mapped buffer. We use 256K, equal to 65536 pages (given a 4K page size). + * Increasing the value beyond the default size will generally not provide any increase in throughput. + */ + private static final int DEFAULT_BUFFER_SIZE = 256 * 1024; + private static final ByteBuffer EMPTY_BUFFER = ByteBuffer.wrap(new byte[0]).asReadOnlyBuffer(); + private final int bufferSize; + private final FileChannel channel; + private ByteBuffer buffer = EMPTY_BUFFER; + private boolean closed; + + /** + * The starting position (within the file) of the next sliding buffer. + */ + private long nextBufferPosition; + + /** + * Constructs a new instance. + * + * @param file The path of the file to to open. + * @throws IOException If an I/O error occurs + */ + public MemoryMappedFileInputStream(final Path file) throws IOException { + this(file, DEFAULT_BUFFER_SIZE); + } + + /** + * Constructs a new instance. + * + * @param file The path of the file to to open. + * @param bufferSize Size of the sliding buffer. + * @throws IOException If an I/O error occurs. + */ + public MemoryMappedFileInputStream(final Path file, final int bufferSize) throws IOException { + this.bufferSize = bufferSize; + this.channel = FileChannel.open(file, StandardOpenOption.READ); + } + + @Override + public int available() throws IOException { + return buffer.remaining(); + } + + private void cleanBuffer() { + if (ByteBufferCleaner.isSupported() && buffer.isDirect()) { + ByteBufferCleaner.clean(buffer); + } + } + + @Override + public void close() throws IOException { + if (!closed) { + cleanBuffer(); + buffer = null; + channel.close(); + closed = true; + } + } + + private void ensureOpen() throws IOException { + if (closed) { + throw new IOException("Stream closed"); + } + } + + private void nextBuffer() throws IOException { + final long remainingInFile = channel.size() - nextBufferPosition; + if (remainingInFile > 0) { + final long amountToMap = Math.min(remainingInFile, bufferSize); + cleanBuffer(); + buffer = channel.map(MapMode.READ_ONLY, nextBufferPosition, amountToMap); + nextBufferPosition += amountToMap; + } else { + buffer = EMPTY_BUFFER; + } + } + + @Override + public int read() throws IOException { + ensureOpen(); + if (!buffer.hasRemaining()) { + nextBuffer(); + if (!buffer.hasRemaining()) { + return EOF; + } + } + return Short.toUnsignedInt(buffer.get()); + } + + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + ensureOpen(); + if (!buffer.hasRemaining()) { + nextBuffer(); + if (!buffer.hasRemaining()) { + return EOF; + } + } + final int numBytes = Math.min(buffer.remaining(), len); + buffer.get(b, off, numBytes); + return numBytes; + } + + @Override + public long skip(final long n) throws IOException { + ensureOpen(); + if (n <= 0) { + return 0; + } + if (n <= buffer.remaining()) { + buffer.position((int) (buffer.position() + n)); + return n; + } + final long remainingInFile = channel.size() - nextBufferPosition; + final long skipped = buffer.remaining() + Math.min(remainingInFile, n - buffer.remaining()); + nextBufferPosition += skipped - buffer.remaining(); + nextBuffer(); + return skipped; + } + +} diff --git a/src/test/java/org/apache/commons/io/input/ByteBufferCleanerTest.java b/src/test/java/org/apache/commons/io/input/ByteBufferCleanerTest.java new file mode 100644 index 0000000..be639a5 --- /dev/null +++ b/src/test/java/org/apache/commons/io/input/ByteBufferCleanerTest.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.io.input; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.ByteBuffer; + +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.Test; + +/** + * Tests {@code ByteBufferCleaner}. + */ +public class ByteBufferCleanerTest { + + @Test + void testCleanEmpty() { + ByteBuffer buffer = ByteBuffer.allocateDirect(10); + // There is no way verify that the buffer has been cleaned up, we are just verifying that + // clean() doesn't blow up + ByteBufferCleaner.clean(buffer); + } + + @Test + void testCleanFull() { + ByteBuffer buffer = ByteBuffer.allocateDirect(10); + buffer.put(RandomUtils.nextBytes(10), 0, 10); + // There is no way verify that the buffer has been cleaned up, we are just verifying that + // clean() doesn't blow up + ByteBufferCleaner.clean(buffer); + } + + @Test + void testSupported() { + assertTrue(ByteBufferCleaner.isSupported(), "ByteBufferCleaner does not work on this platform, please investigate and fix"); + } + +} diff --git a/src/test/java/org/apache/commons/io/input/MemoryMappedFileInputStreamTest.java b/src/test/java/org/apache/commons/io/input/MemoryMappedFileInputStreamTest.java new file mode 100644 index 0000000..2c23c1b --- /dev/null +++ b/src/test/java/org/apache/commons/io/input/MemoryMappedFileInputStreamTest.java @@ -0,0 +1,244 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.io.input; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests {@link MemoryMappedFileInputStream}. + */ +public class MemoryMappedFileInputStreamTest { + + @TempDir + Path tempDir; + + @AfterEach + void cleanup() { + // Ask to run the garbage collector to clean up memory mapped buffers, + // otherwise the temporary files won't be able to be removed when running on + // Windows. Calling gc() is just a hint to the VM. + System.gc(); + } + + private Path createTestFile(final int size) throws IOException { + final Path file = Files.createTempFile(tempDir, null, null); + try (OutputStream outputStream = new BufferedOutputStream(Files.newOutputStream(file))) { + Files.write(file, RandomUtils.nextBytes(size)); + } + return file; + } + + @Test + void testAlternateBufferSize() throws IOException { + // setup + final Path file = createTestFile(1024 * 1024); + final byte[] expectedData = Files.readAllBytes(file); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file, 1024)) { + // verify + assertArrayEquals(expectedData, IOUtils.toByteArray(inputStream)); + } + } + + @Test + void testEmptyFile() throws IOException { + // setup + final Path file = createTestFile(0); + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file)) { + // verify + assertArrayEquals(new byte[0], IOUtils.toByteArray(inputStream)); + } + } + + @Test + void testLargerFile() throws IOException { + // setup + final Path file = createTestFile(1024 * 1024); + final byte[] expectedData = Files.readAllBytes(file); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file)) { + // verify + assertArrayEquals(expectedData, IOUtils.toByteArray(inputStream)); + } + } + + @Test + void testReadAfterClose() throws IOException { + // setup + final Path file = createTestFile(1 * 1024 * 1024); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file, 1024)) { + inputStream.close(); + // verify + Assertions.assertThrows(IOException.class, () -> IOUtils.toByteArray(inputStream)); + } + } + + @Test + void testReadSingleByte() throws IOException { + // setup + final Path file = createTestFile(2); + final byte[] expectedData = Files.readAllBytes(file); + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file, 1024)) { + final int b1 = inputStream.read(); + final int b2 = inputStream.read(); + assertEquals(-1, inputStream.read()); + // verify + assertArrayEquals(expectedData, new byte[] {(byte) b1, (byte) b2}); + } + } + + @Test + void testSkipAtStart() throws IOException { + // setup + final Path file = createTestFile(100); + final byte[] expectedData = Files.readAllBytes(file); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) { + assertEquals(1, inputStream.skip(1)); + final byte[] data = IOUtils.toByteArray(inputStream); + // verify + assertArrayEquals(Arrays.copyOfRange(expectedData, 1, expectedData.length), data); + } + } + + @Test + void testSkipEmpty() throws IOException { + // setup + final Path file = createTestFile(0); + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file)) { + assertEquals(0, inputStream.skip(5)); + // verify + assertArrayEquals(new byte[0], IOUtils.toByteArray(inputStream)); + } + } + + @Test + void testSkipInCurrentBuffer() throws IOException { + // setup + final Path file = createTestFile(100); + final byte[] expectedData = Files.readAllBytes(file); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) { + IOUtils.toByteArray(inputStream, 5); + assertEquals(3, inputStream.skip(3)); + final byte[] data = IOUtils.toByteArray(inputStream); + // verify + assertArrayEquals(Arrays.copyOfRange(expectedData, 8, expectedData.length), data); + } + } + + @ParameterizedTest + @ValueSource(ints = {-5, -1, 0}) + void testSkipNoop(final int amountToSkip) throws IOException { + // setup + final Path file = createTestFile(10); + final byte[] expectedData = Files.readAllBytes(file); + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file)) { + assertEquals(0, inputStream.skip(amountToSkip)); + // verify + assertArrayEquals(expectedData, IOUtils.toByteArray(inputStream)); + } + } + + @Test + void testSkipOutOfCurrentBuffer() throws IOException { + // setup + final Path file = createTestFile(100); + final byte[] expectedData = Files.readAllBytes(file); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) { + IOUtils.toByteArray(inputStream, 5); + assertEquals(6, inputStream.skip(6)); + final byte[] data = IOUtils.toByteArray(inputStream); + // verify + assertArrayEquals(Arrays.copyOfRange(expectedData, 11, expectedData.length), data); + } + } + + @Test + void testSkipPastEof() throws IOException { + // setup + final Path file = createTestFile(100); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) { + IOUtils.toByteArray(inputStream, 5); + assertEquals(95, inputStream.skip(96)); + // verify + assertArrayEquals(new byte[0], IOUtils.toByteArray(inputStream)); + } + } + + @Test + void testSkipToEndOfCurrentBuffer() throws IOException { + // setup + final Path file = createTestFile(100); + final byte[] expectedData = Files.readAllBytes(file); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file, 10)) { + IOUtils.toByteArray(inputStream, 5); + assertEquals(5, inputStream.skip(5)); + final byte[] data = IOUtils.toByteArray(inputStream); + // verify + assertArrayEquals(Arrays.copyOfRange(expectedData, 10, expectedData.length), data); + } + } + + @Test + void testSmallFile() throws IOException { + // setup + final Path file = createTestFile(100); + final byte[] expectedData = Files.readAllBytes(file); + + // test + try (InputStream inputStream = new MemoryMappedFileInputStream(file)) { + // verify + assertArrayEquals(expectedData, IOUtils.toByteArray(inputStream)); + } + } + +}