«1. Обзор

В этой статье мы рассмотрим увлекательный класс, предоставляемый JRE — Unsafe из пакета sun.misc. Этот класс предоставляет нам низкоуровневые механизмы, предназначенные для использования только основной библиотекой Java, а не обычными пользователями.

Это дает нам низкоуровневые механизмы, в первую очередь предназначенные для внутреннего использования в основных библиотеках.

2. Получение экземпляра класса Unsafe

Во-первых, чтобы иметь возможность использовать класс Unsafe, нам нужно получить экземпляр, что не так просто, учитывая, что класс был разработан только для внутреннего использования.

Получить экземпляр можно с помощью статического метода getUnsafe(). Предостережение в том, что по умолчанию это вызовет исключение SecurityException.

К счастью, мы можем получить экземпляр с помощью отражения:

Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
unsafe = (Unsafe) f.get(null);

3. Создание экземпляра класса с помощью Unsafe

Допустим, у нас есть простой класс с конструктором, который устанавливает значение переменной при создании объекта :

class InitializationOrdering {
    private long a;

    public InitializationOrdering() {
        this.a = 1;
    }

    public long getA() {
        return this.a;
    }
}

Когда мы инициализируем этот объект с помощью конструктора, метод getA() вернет значение 1:

InitializationOrdering o1 = new InitializationOrdering();
assertEquals(o1.getA(), 1);

Но мы можем использовать метод allocateInstance() с помощью Unsafe. Он выделит память только для нашего класса и не будет вызывать конструктор:

InitializationOrdering o3 
  = (InitializationOrdering) unsafe.allocateInstance(InitializationOrdering.class);
 
assertEquals(o3.getA(), 0);

Обратите внимание, что конструктор не был вызван, поэтому метод getA() вернул значение по умолчанию для длинного типа — “ что равно 0.

4. Изменение частных полей

Допустим, у нас есть класс, который содержит секретное частное значение:

class SecretHolder {
    private int SECRET_VALUE = 0;

    public boolean secretIsDisclosed() {
        return SECRET_VALUE == 1;
    }
}

Используя метод putInt() из Unsafe, мы можем изменить значение частного поля SECRET_VALUE, изменяя/искажая состояние этого экземпляра:

SecretHolder secretHolder = new SecretHolder();

Field f = secretHolder.getClass().getDeclaredField("SECRET_VALUE");
unsafe.putInt(secretHolder, unsafe.objectFieldOffset(f), 1);

assertTrue(secretHolder.secretIsDisclosed());

Как только мы получаем поле с помощью вызова отражения, мы можем изменить его значение на любое другое значение int, используя метод Unsafe.

5. Генерация исключения

Код, вызываемый через Unsafe, не проверяется компилятором таким же образом, как обычный код Java. Мы можем использовать метод throwException() для генерации любого исключения, не ограничивая вызывающую программу обработкой этого исключения, даже если это проверенное исключение:

@Test(expected = IOException.class)
public void givenUnsafeThrowException_whenThrowCheckedException_thenNotNeedToCatchIt() {
    unsafe.throwException(new IOException());
}

После генерации исключения IOException, которое проверяется, нам не нужно перехватывать его и не указывать в объявлении метода.

6. Память вне кучи

Если приложению не хватает доступной памяти на JVM, мы можем в конечном итоге заставить процесс GC запускаться слишком часто. В идеале нам нужна особая область памяти, вне кучи и не контролируемая процессом GC.

Метод allocateMemory() из класса Unsafe дает нам возможность выделять огромные объекты из кучи, а это означает, что эта память не будет видна и учтена сборщиком мусора и JVM.

Это может быть очень полезно, но мы должны помнить, что этой памятью нужно управлять вручную и правильно освобождать с помощью freeMemory(), когда она больше не нужна.

Допустим, мы хотим создать большой массив байтов памяти вне кучи. Для этого мы можем использовать метод allocateMemory():

class OffHeapArray {
    private final static int BYTE = 1;
    private long size;
    private long address;

    public OffHeapArray(long size) throws NoSuchFieldException, IllegalAccessException {
        this.size = size;
        address = getUnsafe().allocateMemory(size * BYTE);
    }

    private Unsafe getUnsafe() throws IllegalAccessException, NoSuchFieldException {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        return (Unsafe) f.get(null);
    }

    public void set(long i, byte value) throws NoSuchFieldException, IllegalAccessException {
        getUnsafe().putByte(address + i * BYTE, value);
    }

    public int get(long idx) throws NoSuchFieldException, IllegalAccessException {
        return getUnsafe().getByte(address + idx * BYTE);
    }

    public long size() {
        return size;
    }
    
    public void freeMemory() throws NoSuchFieldException, IllegalAccessException {
        getUnsafe().freeMemory(address);
    }
}

В конструкторе OffHeapArray мы инициализируем массив заданного размера. Мы сохраняем начальный адрес массива в поле адреса. Метод set() принимает индекс и заданное значение, которое будет храниться в массиве. Метод get() извлекает значение байта, используя его индекс, который является смещением от начального адреса массива.

long SUPER_SIZE = (long) Integer.MAX_VALUE * 2;
OffHeapArray array = new OffHeapArray(SUPER_SIZE);

Затем мы можем выделить этот массив вне кучи, используя его конструктор:

int sum = 0;
for (int i = 0; i < 100; i++) {
    array.set((long) Integer.MAX_VALUE + i, (byte) 3);
    sum += array.get((long) Integer.MAX_VALUE + i);
}

assertEquals(array.size(), SUPER_SIZE);
assertEquals(sum, 300);

Мы можем поместить N значений байтов в этот массив, а затем извлечь эти значения, суммируя их, чтобы проверить, правильно ли работает наша адресация. :

В конце нам нужно освободить память обратно в ОС, вызвав freeMemory().

7. Операция CompareAndSwap

«Очень эффективные конструкции из пакета java.concurrent, такие как AtomicInteger, используют методы compareAndSwap() из Unsafe, чтобы обеспечить максимально возможную производительность. Эта конструкция широко используется в алгоритмах блокировки без блокировки, которые могут использовать инструкции процессора CAS для обеспечения значительного ускорения по сравнению со стандартным механизмом пессимистической синхронизации в Java.

class CASCounter {
    private Unsafe unsafe;
    private volatile long counter = 0;
    private long offset;

    private Unsafe getUnsafe() throws IllegalAccessException, NoSuchFieldException {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        return (Unsafe) f.get(null);
    }

    public CASCounter() throws Exception {
        unsafe = getUnsafe();
        offset = unsafe.objectFieldOffset(CASCounter.class.getDeclaredField("counter"));
    }

    public void increment() {
        long before = counter;
        while (!unsafe.compareAndSwapLong(this, offset, before, before + 1)) {
            before = counter;
        }
    }

    public long getCounter() {
        return counter;
    }
}

Мы можем построить счетчик на основе CAS, используя метод compareAndSwapLong() из Unsafe:

В конструкторе CASCounter мы получаем адрес поля счетчика, чтобы иметь возможность использовать его позже в приращении( ) метод. Это поле должно быть объявлено как volatile, чтобы его могли видеть все потоки, записывающие и считывающие это значение. Мы используем метод objectFieldOffset() для получения адреса памяти поля смещения.

Наиболее важной частью этого класса является метод increment(). Мы используем compareAndSwapLong() в цикле while для увеличения ранее извлеченного значения, проверяя, изменилось ли это предыдущее значение с момента его извлечения.

Если да, то мы повторяем эту операцию до тех пор, пока не добьемся успеха. Здесь нет блокировки, поэтому этот алгоритм называется lock-free.

int NUM_OF_THREADS = 1_000;
int NUM_OF_INCREMENTS = 10_000;
ExecutorService service = Executors.newFixedThreadPool(NUM_OF_THREADS);
CASCounter casCounter = new CASCounter();

IntStream.rangeClosed(0, NUM_OF_THREADS - 1)
  .forEach(i -> service.submit(() -> IntStream
    .rangeClosed(0, NUM_OF_INCREMENTS - 1)
    .forEach(j -> casCounter.increment())));

Мы можем протестировать наш код, увеличив общий счетчик из нескольких потоков:

assertEquals(NUM_OF_INCREMENTS * NUM_OF_THREADS, casCounter.getCounter());

Затем, чтобы подтвердить правильность состояния счетчика, мы можем получить из него значение счетчика:

8. Park/Unpark

В Unsafe API есть два интересных метода, которые используются JVM для контекстного переключения потоков. Когда поток ожидает какого-либо действия, JVM может заблокировать этот поток, используя метод park() из класса Unsafe.

Он очень похож на метод Object.wait(), но вызывает собственный код ОС, таким образом, используя некоторые особенности архитектуры для достижения наилучшей производительности.

Когда поток заблокирован и его нужно снова сделать работоспособным, JVM использует метод unpark(). Мы часто будем видеть вызовы этих методов в дампах потоков, особенно в приложениях, использующих пулы потоков.

9. Заключение

В этой статье мы рассмотрели класс Unsafe и его наиболее полезные конструкции.

Мы увидели, как получить доступ к закрытым полям, как выделить память вне кучи и как использовать конструкцию сравнения и замены для реализации алгоритмов без блокировок.