Radix sort with integer divisions
Bugs

@JS1's answer was spot-on:
the counting of digits this way was buggy:


    int maxDigits = 1 + (int) (Math.log(max(arr)) / Math.log(radix));



Because Math.log(...) gives NaN for negative numbers,
and negative infinity for 0,
which breaks the digit count and the sorting.

This unit test exposed the bug:

@Test
public void test_m300_m200_m999() {
    sortAndVerify(new int[]{-30, -200, -999});
}


I could fix this by finding the max absolute value.

private static int getMaxAbs(int[] arr) {
    int maxAbs = 0;
    for (int num : arr) {
        int absNum = Math.abs(num);
        if (absNum &gt; maxAbs) {
            maxAbs = absNum;
        }
    }
    return maxAbs;
}




There is another bug, related to floating point arithmetics.
Even after taking the max of absolute values for counting digits,
this test still fails:

@Test
public void test_m300_m200_m1000() {
    sortAndVerify(new int[]{-300, -200, -1000});
}


The problem is that Math.log(1000) / Math.log(10) doesn't give 3 as expected,
but 2.9999999999999996, which then gets truncated to 2 in the line that calculates maxDigits.

The fix for this is to count the digits in a simple loop instead of using divisions with Math.log that is prone to floating point arithmetic issues:

private static int countDigits(int num, int radix) {
    if (num == 0) {
        return 1;
    }
    int work = num;
    int count = 0;
    while (work &gt; 0) {
        work /= radix;
        ++count;
    }
    return count;
}


Optimize

This step is a bit wasteful:


    List&lt;List&lt;Integer&gt;&gt; buckets = splitBySign(arr);
    flattenBuckets(arr, buckets);



The splitting step will visit all elements to create the buckets,
and then the flatting step will visit all elements again to write back to the array.

A more efficient approach is possible:


Create a helper array to store the negative numbers in the right order
Iterate from the back of the target array


If the current item is negative, write it to the next position in the helper array
If the current item is positive, write it to the next position from the back of the target array

After one pass, the positive numbers will be clustered at the end of the target array, in the correct order, and the negative numbers will be at the beginning of the helper array
Copy the beginning of the helper array to target


Implementation:

private static void reverseAndShiftNegatives(int[] arr) {
    int len = arr.length;
    int[] negative = new int[len];
    int negativeFront = 0;
    int back = len - 1;
    for (int i = back; i &gt;= 0; --i) {
        int num = arr[i];
        if (num &gt; 0) {
            arr[back--] = num;
        } else {
            negative[negativeFront++] = num;
        }
    }
    System.arraycopy(negative, 0, arr, 0, negativeFront);
}