Created
October 7, 2025 14:47
-
-
Save ichenhe/9632d8204b2e0dec19f81bfb0cd867a5 to your computer and use it in GitHub Desktop.
A simple ULID implement in Java with unit test
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import com.google.common.annotations.VisibleForTesting; | |
| import java.util.concurrent.ThreadLocalRandom; | |
| import java.util.concurrent.atomic.AtomicLong; | |
| import java.util.concurrent.atomic.AtomicReference; | |
| /** | |
| * A ULID generator that follows the <a href="https://github.com/ulid/spec">ULID spec.</a> | |
| */ | |
| public class ULID { | |
| // Crockford's Base32 | |
| private static final char[] ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray(); | |
| private static final int TIME_BYTES = 6; | |
| private static final int RAMDOM_BYTES = 10; | |
| private static final AtomicLong lastTime = new AtomicLong(Long.MIN_VALUE); | |
| private static final AtomicReference<byte[]> lastBytes = new AtomicReference<>(new byte[17]); | |
| public static String next() { | |
| byte[] ulidBytes; | |
| while (true) { | |
| final long last = lastTime.get(); | |
| final long cur = System.currentTimeMillis(); | |
| if (last == cur) { | |
| final byte[] lastBytesVal = lastBytes.get(); | |
| ulidBytes = lastBytesVal.clone(); | |
| if (!incrementLastRandom(ulidBytes)) { | |
| // overflow, retry | |
| continue; | |
| } | |
| if (!lastBytes.compareAndSet(lastBytesVal, ulidBytes)) { | |
| continue; | |
| } | |
| } else { | |
| ulidBytes = new byte[TIME_BYTES + RAMDOM_BYTES + 1]; | |
| ThreadLocalRandom.current().nextBytes(ulidBytes); | |
| // ulidBytes[0]: padding 0 so that we have 130 bits in total (26 chars * 5 bit/char) | |
| ulidBytes[0] = 0; | |
| // set first 6B, 48bits: timestamp, big-endian | |
| ulidBytes[1] = (byte) ((cur >>> 40) & 0xFF); | |
| ulidBytes[2] = (byte) ((cur >>> 32) & 0xFF); | |
| ulidBytes[3] = (byte) ((cur >>> 24) & 0xFF); | |
| ulidBytes[4] = (byte) ((cur >>> 16) & 0xFF); | |
| ulidBytes[5] = (byte) ((cur >>> 8) & 0xFF); | |
| ulidBytes[6] = (byte) (cur & 0xFF); | |
| lastBytes.set(ulidBytes); | |
| } | |
| if (lastTime.compareAndSet(last, cur)) { | |
| break; | |
| } | |
| } | |
| // encode | |
| final char[] chars = new char[26]; | |
| for (int i = 0; i < 26; i++) { | |
| int bitIndex = 6 + i * 5; // skip the redundant 6 bits (17*8 - 26char * 5) | |
| int val = readFiveBits(ulidBytes, bitIndex); | |
| chars[i] = ALPHABET[val]; | |
| } | |
| return new String(chars); | |
| } | |
| @VisibleForTesting | |
| static boolean incrementLastRandom(final byte[] last) { | |
| for (int i = last.length - 1; i >= last.length - RAMDOM_BYTES; i--) { | |
| int v = (last[i] & 0xFF) + 1; | |
| last[i] = (byte) v; | |
| if ((v & 0x100) == 0) { | |
| // no carry bit | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Read 5 bits from the byte array starting at the specified bit offset, as an int. | |
| * | |
| * @return [0, 31] | |
| */ | |
| private static int readFiveBits(byte[] bytes, int bitOffset) { | |
| int value = 0; | |
| for (int i = 0; i < 5; i++) { | |
| final int bitIndex = bitOffset + i; | |
| final int byteIndex = bitIndex / 8; | |
| final int bitIndexInByte = bitIndex - byteIndex * 8; | |
| int bit = (bytes[byteIndex] >> (7 - bitIndexInByte)) & 1; | |
| value = (value << 1) | bit; | |
| } | |
| return value; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import org.junit.Test; | |
| import org.junit.jupiter.params.ParameterizedTest; | |
| import org.junit.jupiter.params.provider.Arguments; | |
| import org.junit.jupiter.params.provider.MethodSource; | |
| import javax.annotation.Nullable; | |
| import java.util.List; | |
| import java.util.stream.Collectors; | |
| import java.util.stream.IntStream; | |
| import java.util.stream.Stream; | |
| public class ULIDTest { | |
| @Test | |
| public void generateULID() { | |
| final String id = ULID.next(); | |
| assert id.length() == 26; | |
| // the largest valid ULID encoded in base32 is 7ZZZZZZZZZZZZZZZZZZZZZZZZZ (2bits padding 0 + 48bits 1 + 80bits 1) | |
| assert id.charAt(0) <= '7'; | |
| assert id.charAt(1) <= 'Z'; | |
| } | |
| @Test | |
| public void ulidShouldMonotonous() { | |
| final List<String> ids = IntStream.range(0, 2000) | |
| .mapToObj(i -> ULID.next()) | |
| .collect(Collectors.toList()); | |
| for (int i = 1; i < ids.size(); i++) { | |
| assert ids.get(i).compareTo(ids.get(i - 1)) > 0; | |
| } | |
| } | |
| private static Stream<Arguments> incrementTestCases() { | |
| return Stream.of( | |
| Arguments.of( | |
| new int[]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, | |
| null | |
| ), // overflow | |
| Arguments.of( | |
| new int[]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE}, | |
| new int[]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF} | |
| ), // no carry | |
| Arguments.of( | |
| new int[]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x11, 0xFF, 0xFF, 0xFF}, | |
| new int[]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x12, 0x00, 0x00, 0x00} | |
| ) // continues carry | |
| ); | |
| } | |
| @ParameterizedTest | |
| @MethodSource("incrementTestCases") | |
| public void increment(final int[] before, @Nullable final int[] after) { | |
| assert after == null || before.length == after.length : "Expect before and after have same length"; | |
| final byte[] bytes = new byte[before.length]; | |
| for (int i = 0; i < before.length; i++) { | |
| bytes[i] = (byte) before[i]; | |
| } | |
| final boolean succeed = ULID.incrementLastRandom(bytes); | |
| if (after == null) { | |
| assert !succeed : "Expect failed to increment, but succeed."; | |
| return; | |
| } | |
| assert succeed : "Expect to succeed, but it failed."; | |
| final byte[] expect = new byte[after.length]; | |
| for (int i = 0; i < expect.length; i++) { | |
| expect[i] = (byte) after[i]; | |
| } | |
| for (int i = 0; i < expect.length; i++) { | |
| assert bytes[i] == (expect[i]) : String.format("Expect bytes[%d] is 0x%02X but got 0x%02X", i, expect[i], bytes[i]); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment